From e8cab9a768308c1a5066fee1adda307e66834d3f Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 25 Aug 2026 20:00:25 +0530 Subject: [PATCH 01/31] fix(e2e): batch Applitools checks per diagram folder with a run-wide id After the Playwright migration every Applitools batch was fragmented and the ~1000 migrated .mmd fixtures all landed under a single mmd-snapshots.spec.ts entry. - The batch id was seeded with Date.now() at module load. Under Cypress the helper loaded once per spec file; under Playwright it loads once per worker, so with fullyParallel a spec's tests were split across as many batches as workers touched it. Seed the id once in playwright.config.ts (the runner process, before workers fork and inherit process.env); CI passes a per-dispatch id (sha + run_id) so re-dispatching on the same commit gets fresh batches. - Batch by the same grouping the Argos sheets use: mmd fixtures by their diagram folder (diagrams/flowchart, diagrams/flowchart/elk, ...), spec-based tests by spec path. - Name Applitools tests within the batch: mmd fixtures by base name, spec tests without the spec-file prefix Playwright prepends to titlePath (restoring the Cypress-era `describe title` names). Explicit names are unchanged. - Drop PLAYWRIGHT_COMMIT from the Argos workflows; it only ever fed the old Applitools seed. Co-Authored-By: Claude Fable 5 --- .github/workflows/e2e-applitools.yml | 5 +- .github/workflows/e2e.yml | 1 - .github/workflows/refresh-sheet-order.yml | 1 - e2e/helpers/applitools.spec.ts | 77 +++++++++++++++++++++++ e2e/helpers/applitools.ts | 47 ++++++++++++++ e2e/helpers/util.ts | 33 +++++----- playwright.config.ts | 8 +++ 7 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 e2e/helpers/applitools.spec.ts create mode 100644 e2e/helpers/applitools.ts diff --git a/.github/workflows/e2e-applitools.yml b/.github/workflows/e2e-applitools.yml index 761a6a747d5..8c1794ff604 100644 --- a/.github/workflows/e2e-applitools.yml +++ b/.github/workflows/e2e-applitools.yml @@ -49,7 +49,10 @@ jobs: env: MERMAID_PORT: 9000 APPLITOOLS_API_KEY: ${{ secrets.APPLITOOLS_API_KEY }} - APPLITOOLS_BATCH_ID: ${{ github.sha }} + # Per-dispatch seed for the per-folder/spec batch ids (see + # e2e/helpers/applitools.ts). Includes run_id so re-dispatching on the + # same commit gets fresh batches instead of joining the previous ones. + APPLITOOLS_BATCH_ID: ${{ github.sha }}-${{ github.run_id }} # e.g. mermaid-js/mermaid/my-branch APPLITOOLS_BRANCH: ${{ github.repository }}/${{ github.ref_name }} APPLITOOLS_PARENT_BRANCH: ${{ github.event.inputs.parent_branch }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6ecb64005f5..ed323397f82 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -304,7 +304,6 @@ jobs: id: playwright env: MERMAID_PORT: 9000 - PLAYWRIGHT_COMMIT: ${{ github.sha }} # Enables native V8 coverage collection (page.coverage) on coverage runs. E2E_COVERAGE: ${{ (github.event_name == 'pull_request' || github.ref == 'refs/heads/develop') && 'true' || '' }} # Job output via env — never interpolate spec globs into the shell via ${{ }}. diff --git a/.github/workflows/refresh-sheet-order.yml b/.github/workflows/refresh-sheet-order.yml index edb0e7edc03..37968de904c 100644 --- a/.github/workflows/refresh-sheet-order.yml +++ b/.github/workflows/refresh-sheet-order.yml @@ -56,7 +56,6 @@ jobs: env: MERMAID_PORT: 9000 RUN_VISUAL_TEST: 'true' - PLAYWRIGHT_COMMIT: ${{ github.sha }} - name: Regenerate sheet order run: pnpm run screenshots:order diff --git a/e2e/helpers/applitools.spec.ts b/e2e/helpers/applitools.spec.ts new file mode 100644 index 00000000000..af303d7ebe3 --- /dev/null +++ b/e2e/helpers/applitools.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { applitoolsBatch, applitoolsTestName } from './applitools.ts'; + +describe('applitoolsBatch', () => { + it('groups mmd fixtures by their diagram folder, not the runner spec', () => { + expect( + applitoolsBatch('run1', 'rendering/mmd-snapshots.spec.ts', 'diagrams/flowchart/foo') + ).toEqual({ id: 'mermaid-batch-run1-diagrams/flowchart', name: 'diagrams/flowchart' }); + }); + + it('keeps nested fixture folders as their own batch', () => { + expect( + applitoolsBatch( + 'run1', + 'rendering/mmd-snapshots.spec.ts', + 'diagrams/c4/characterization/boundaries/x' + ).name + ).toBe('diagrams/c4/characterization/boundaries'); + }); + + it('puts every fixture in a folder into the same batch', () => { + const a = applitoolsBatch('run1', 'rendering/mmd-snapshots.spec.ts', 'diagrams/packet/a'); + const b = applitoolsBatch('run1', 'rendering/mmd-snapshots.spec.ts', 'diagrams/packet/b'); + expect(a).toEqual(b); + }); + + it('groups spec-based tests by the spec path', () => { + expect(applitoolsBatch('run1', 'rendering/flowchart/flowchart.spec.js')).toEqual({ + id: 'mermaid-batch-run1-rendering/flowchart/flowchart.spec.js', + name: 'rendering/flowchart/flowchart.spec.js', + }); + }); + + it('falls back to the spec when the screenshot path has no folder', () => { + expect(applitoolsBatch('run1', 'other/xss.spec.js', 'flat').name).toBe('other/xss.spec.js'); + }); + + it('separates runs by run id', () => { + const a = applitoolsBatch('run1', 'rendering/theme.spec.js'); + const b = applitoolsBatch('run2', 'rendering/theme.spec.js'); + expect(a.name).toBe(b.name); + expect(a.id).not.toBe(b.id); + }); +}); + +describe('applitoolsTestName', () => { + it('names mmd fixtures by their base name (the batch already names the folder)', () => { + expect( + applitoolsTestName( + 'rendering/mmd-snapshots.spec.ts-mmd-snapshots-flowchart-foo', + 'rendering/mmd-snapshots.spec.ts', + 'diagrams/flowchart/foo' + ) + ).toBe('foo'); + }); + + it('drops the spec-file prefix Playwright prepends to the title path', () => { + expect( + applitoolsTestName( + 'rendering/flowchart/flowchart.spec.js-Flowchart-1:-should-render', + 'rendering/flowchart/flowchart.spec.js' + ) + ).toBe('Flowchart-1:-should-render'); + }); + + it('keeps explicit names untouched', () => { + expect(applitoolsTestName('Basic-States', 'rendering/state/stateDiagram-neo.spec.js')).toBe( + 'Basic-States' + ); + }); + + it('falls back to the spec rule when the screenshot path has no folder', () => { + expect(applitoolsTestName('other/xss.spec.js-XSS-1', 'other/xss.spec.js', 'flat')).toBe( + 'XSS-1' + ); + }); +}); diff --git a/e2e/helpers/applitools.ts b/e2e/helpers/applitools.ts new file mode 100644 index 00000000000..060d9f13109 --- /dev/null +++ b/e2e/helpers/applitools.ts @@ -0,0 +1,47 @@ +export interface ApplitoolsBatch { + id: string; + name: string; +} + +/** + * Applitools batch for a screenshot, grouped the same way the Argos sheets are + * (see `deriveGroupKey` in scripts/screenshot-sheets.ts): mmd fixtures batch by + * their diagram folder (e.g. `diagrams/flowchart`, `diagrams/flowchart/elk`) + * rather than the single runner spec they all execute from; spec-based tests + * batch by the spec's path relative to the e2e dir. + * + * `runId` must be identical across every Playwright worker of one run — the + * batch id is what Applitools keys on, so a per-worker seed splits one spec or + * folder into as many batches as workers touched it. + */ +export const applitoolsBatch = ( + runId: string, + specRelPath: string, + screenshotPath?: string +): ApplitoolsBatch => { + const segments = screenshotPath?.split('/') ?? []; + const name = segments.length > 1 ? segments.slice(0, -1).join('/') : specRelPath; + return { id: `mermaid-batch-${runId}-${name}`, name }; +}; + +/** + * Applitools test name for a screenshot. `name` is the snapshot name util.ts + * derives for every backend (explicit `options.name`, or the whitespace-collapsed + * Playwright title path, which starts with the spec file). Since the batch + * already names the folder or spec, the test name only needs to identify the + * test within it: mmd fixtures use their base name, spec-based tests drop the + * spec-file prefix (restoring the Cypress-era `describe title` names). Explicit + * names carry no such prefix and pass through unchanged. + */ +export const applitoolsTestName = ( + name: string, + specRelPath: string, + screenshotPath?: string +): string => { + const segments = screenshotPath?.split('/') ?? []; + if (segments.length > 1) { + return segments[segments.length - 1]; + } + const specPrefix = `${specRelPath.replace(/\s+/g, '-')}-`; + return name.startsWith(specPrefix) ? name.slice(specPrefix.length) : name; +}; diff --git a/e2e/helpers/util.ts b/e2e/helpers/util.ts index dffe003bb6f..cd5e0b320a7 100644 --- a/e2e/helpers/util.ts +++ b/e2e/helpers/util.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { basename, dirname, join, relative, sep } from 'node:path'; +import { dirname, join, relative, sep } from 'node:path'; import { expect, type Page, type TestInfo } from '@playwright/test'; import { Buffer } from 'buffer'; import type { MermaidConfig } from '../../packages/mermaid/src/config.type.js'; +import { applitoolsBatch, applitoolsTestName } from './applitools.ts'; import { buildCaptureMetadata, writeArgosMetadataSidecar } from './argos-metadata.ts'; import { collectCoverage, startCoverage } from './coverage.js'; @@ -34,12 +35,6 @@ export const utf8ToB64 = (str: string): string => { return Buffer.from(decodeURIComponent(encodeURIComponent(str))).toString('base64'); }; -const batchId: string = - 'mermaid-batch-' + - (process.env.USE_APPLI - ? Date.now().toString() - : (process.env.PLAYWRIGHT_COMMIT ?? Date.now().toString())); - /** Keep screenshot names within filesystem limits (ENAMETOOLONG on long test titles). */ const shortenScreenshotName = (name: string, maxLen = 180): string => { const sanitized = name.replace(/\s+/g, '-'); @@ -226,21 +221,32 @@ export const verifyScreenshot = async ( const svg = diagramSvg(page).first(); const hasSvg = (await svg.count()) > 0; const target = hasSvg ? svg : page; + // Spec path relative to the e2e dir (e.g. rendering/flowchart/flowchart.spec.js); + // the grouping unit for both Applitools batches and Argos sheets. + const specRelPath = relative(testInfo.project.testDir, testInfo.file).split(sep).join('/'); if (useAppli) { - // Mirrors the Cypress eyes integration: one Applitools batch per spec file, - // a check per screenshot scoped to the diagram SVG (full window when there - // is none). API key, branch, and parent branch are read from the APPLITOOLS_* + // One Applitools batch per diagram folder (mmd fixtures) or spec file, a + // check per screenshot scoped to the diagram SVG (full window when there is + // none). API key, branch, and parent branch are read from the APPLITOOLS_* // env vars by the SDK. Imported lazily so the SDK is only loaded for // Applitools runs, not for Argos/local snapshot runs. const { Eyes, ClassicRunner, Target } = await import('@applitools/eyes-playwright'); - const specName = basename(testInfo.file); + // Shared by every worker: CI passes a per-dispatch id, otherwise + // playwright.config.ts seeds it once in the runner process before forking + // workers. A per-worker seed here would split each batch per worker. + const runId = process.env.APPLITOOLS_BATCH_ID; + if (!runId) { + throw new Error( + 'APPLITOOLS_BATCH_ID is unset; playwright.config.ts should seed it for USE_APPLI runs' + ); + } const eyes = new Eyes(new ClassicRunner()); eyes.setConfiguration({ appName: 'Mermaid', - batch: { id: batchId + specName, name: specName }, + batch: applitoolsBatch(runId, specRelPath, screenshotPath), }); - await eyes.open(page, 'Mermaid', name); + await eyes.open(page, 'Mermaid', applitoolsTestName(name, specRelPath, screenshotPath)); await eyes.check( 'Click!', hasSvg ? Target.region('svg[aria-roledescription]') : Target.window().fully() @@ -266,7 +272,6 @@ export const verifyScreenshot = async ( // GitHub artifacts reject (" : < > | * ?) or path separators; flatten it to // a safe slug. The spec folder lives in specRelPath, which the batch job // groups by, so the filename only needs to be unique within the spec. - const specRelPath = relative(testInfo.project.testDir, testInfo.file).split(sep).join('/'); outPath = join(screenshotDir, specRelPath, `${sanitizeSegment(name)}.png`); } mkdirSync(dirname(outPath), { recursive: true }); diff --git a/playwright.config.ts b/playwright.config.ts index fab300dc440..f4e7cf1f12f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,14 @@ process.env.MERMAID_DEV_PORT ??= port; const devCommand = process.env.E2E_COVERAGE ? 'pnpm dev:coverage' : 'pnpm dev'; +// Applitools batches are keyed on this id (see e2e/helpers/applitools.ts), so it +// has to be identical in every worker. This file is evaluated in the runner +// process before workers fork (they inherit process.env), which makes it the one +// place a per-run seed can be minted; CI sets it per workflow dispatch instead. +if (process.env.USE_APPLI) { + process.env.APPLITOOLS_BATCH_ID ??= Date.now().toString(); +} + export default defineConfig({ testDir: 'e2e', testMatch: '**/*.spec.{js,ts}', From 461a6b3cdea69bf32c6bff80d417724141641763 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 25 Aug 2026 20:03:16 +0530 Subject: [PATCH 02/31] chore(e2e): trim explanatory comments in Applitools batching Co-Authored-By: Claude Fable 5 --- .github/workflows/e2e-applitools.yml | 3 --- e2e/helpers/applitools.ts | 22 ++-------------------- e2e/helpers/util.ts | 14 ++++---------- playwright.config.ts | 5 +---- 4 files changed, 7 insertions(+), 37 deletions(-) diff --git a/.github/workflows/e2e-applitools.yml b/.github/workflows/e2e-applitools.yml index 8c1794ff604..1d4f2e04bfd 100644 --- a/.github/workflows/e2e-applitools.yml +++ b/.github/workflows/e2e-applitools.yml @@ -49,9 +49,6 @@ jobs: env: MERMAID_PORT: 9000 APPLITOOLS_API_KEY: ${{ secrets.APPLITOOLS_API_KEY }} - # Per-dispatch seed for the per-folder/spec batch ids (see - # e2e/helpers/applitools.ts). Includes run_id so re-dispatching on the - # same commit gets fresh batches instead of joining the previous ones. APPLITOOLS_BATCH_ID: ${{ github.sha }}-${{ github.run_id }} # e.g. mermaid-js/mermaid/my-branch APPLITOOLS_BRANCH: ${{ github.repository }}/${{ github.ref_name }} diff --git a/e2e/helpers/applitools.ts b/e2e/helpers/applitools.ts index 060d9f13109..fcd02d260ce 100644 --- a/e2e/helpers/applitools.ts +++ b/e2e/helpers/applitools.ts @@ -3,17 +3,7 @@ export interface ApplitoolsBatch { name: string; } -/** - * Applitools batch for a screenshot, grouped the same way the Argos sheets are - * (see `deriveGroupKey` in scripts/screenshot-sheets.ts): mmd fixtures batch by - * their diagram folder (e.g. `diagrams/flowchart`, `diagrams/flowchart/elk`) - * rather than the single runner spec they all execute from; spec-based tests - * batch by the spec's path relative to the e2e dir. - * - * `runId` must be identical across every Playwright worker of one run — the - * batch id is what Applitools keys on, so a per-worker seed splits one spec or - * folder into as many batches as workers touched it. - */ +/** One batch per diagram folder (mmd fixtures) or spec file, like the Argos sheets. */ export const applitoolsBatch = ( runId: string, specRelPath: string, @@ -24,15 +14,7 @@ export const applitoolsBatch = ( return { id: `mermaid-batch-${runId}-${name}`, name }; }; -/** - * Applitools test name for a screenshot. `name` is the snapshot name util.ts - * derives for every backend (explicit `options.name`, or the whitespace-collapsed - * Playwright title path, which starts with the spec file). Since the batch - * already names the folder or spec, the test name only needs to identify the - * test within it: mmd fixtures use their base name, spec-based tests drop the - * spec-file prefix (restoring the Cypress-era `describe title` names). Explicit - * names carry no such prefix and pass through unchanged. - */ +/** Test name within the batch: fixture base name, or the title path without the spec-file prefix. */ export const applitoolsTestName = ( name: string, specRelPath: string, diff --git a/e2e/helpers/util.ts b/e2e/helpers/util.ts index cd5e0b320a7..3a0f2b469e1 100644 --- a/e2e/helpers/util.ts +++ b/e2e/helpers/util.ts @@ -221,20 +221,14 @@ export const verifyScreenshot = async ( const svg = diagramSvg(page).first(); const hasSvg = (await svg.count()) > 0; const target = hasSvg ? svg : page; - // Spec path relative to the e2e dir (e.g. rendering/flowchart/flowchart.spec.js); - // the grouping unit for both Applitools batches and Argos sheets. const specRelPath = relative(testInfo.project.testDir, testInfo.file).split(sep).join('/'); if (useAppli) { - // One Applitools batch per diagram folder (mmd fixtures) or spec file, a - // check per screenshot scoped to the diagram SVG (full window when there is - // none). API key, branch, and parent branch are read from the APPLITOOLS_* - // env vars by the SDK. Imported lazily so the SDK is only loaded for - // Applitools runs, not for Argos/local snapshot runs. + // One Applitools batch per diagram folder or spec file, a check per screenshot + // scoped to the diagram SVG (full window when there is none). API key, branch, + // and parent branch are read from the APPLITOOLS_* env vars by the SDK. + // Imported lazily so the SDK is only loaded for Applitools runs. const { Eyes, ClassicRunner, Target } = await import('@applitools/eyes-playwright'); - // Shared by every worker: CI passes a per-dispatch id, otherwise - // playwright.config.ts seeds it once in the runner process before forking - // workers. A per-worker seed here would split each batch per worker. const runId = process.env.APPLITOOLS_BATCH_ID; if (!runId) { throw new Error( diff --git a/playwright.config.ts b/playwright.config.ts index f4e7cf1f12f..7f9ed76424c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,10 +27,7 @@ process.env.MERMAID_DEV_PORT ??= port; const devCommand = process.env.E2E_COVERAGE ? 'pnpm dev:coverage' : 'pnpm dev'; -// Applitools batches are keyed on this id (see e2e/helpers/applitools.ts), so it -// has to be identical in every worker. This file is evaluated in the runner -// process before workers fork (they inherit process.env), which makes it the one -// place a per-run seed can be minted; CI sets it per workflow dispatch instead. +// Seeded in the runner process so every worker inherits the same id. if (process.env.USE_APPLI) { process.env.APPLITOOLS_BATCH_ID ??= Date.now().toString(); } From def4c812e5dd4f6e089055ce2590abdae0269ee7 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Thu, 27 Aug 2026 15:06:32 +0200 Subject: [PATCH 03/31] fix(themes): make the redux colour themes supersets of their base themes `redux-color` / `redux-dark-color` were forked from `redux` / `redux-dark` by copy-paste and had drifted. Variables the base themes define were either missing outright or silently re-derived from the grey `primaryColor`: redux-color primaryBorderColor, clusterBkg, clusterBorder, altBackground, compositeTitleBackground, stateEdgeLabelBackground, requirementEdgeLabelBackground redux-dark-color compositeBackground, altBackground, compositeTitleBackground, stateEdgeLabelBackground, requirementEdgeLabelBackground Nothing crashed when that happened -- diagrams just picked up an untuned value where the base theme had a deliberate one, which is invisible in review and only shows up in a screenshot. Also wire up the chart diagrams that read flat `pieN` / `fillTypeN` / `sectionBkgColor` variables rather than the `cScale` array, and so had never picked up the colour themes' palette at all: - pie slices now come from the theme's categorical scale. Every `pieN` used to be a tint of one pale lavender; `pie3` resolved to pure white in `redux-color` and to near-black in `redux-dark-color`. - gantt section bands use two palette hues. Both section colours were white (light) or near-black (dark), and gantt paints them at 20% opacity, so there was no visible banding. - user-journey gets eight hue-distinct section fills: pale in the light theme, dark in the dark theme, since journey task labels use the theme's `textColor`. Add `theme-redux-color-superset.spec.ts`, which pins that a colour theme defines everything its base theme defines and diverges only on an explicitly listed palette, so widening that list is a deliberate edit rather than something a loose pattern lets through. --- .changeset/redux-color-theme-superset.md | 15 +++ .../themes/theme-redux-color-superset.spec.ts | 123 ++++++++++++++++++ .../mermaid/src/themes/theme-redux-color.js | 52 ++++---- .../src/themes/theme-redux-dark-color.js | 66 ++++++---- 4 files changed, 206 insertions(+), 50 deletions(-) create mode 100644 .changeset/redux-color-theme-superset.md create mode 100644 packages/mermaid/src/themes/theme-redux-color-superset.spec.ts diff --git a/.changeset/redux-color-theme-superset.md b/.changeset/redux-color-theme-superset.md new file mode 100644 index 00000000000..b65fd227cd8 --- /dev/null +++ b/.changeset/redux-color-theme-superset.md @@ -0,0 +1,15 @@ +--- +'mermaid': patch +--- + +fix(themes): `redux-color` and `redux-dark-color` now define every theme variable their base themes (`redux` / `redux-dark`) define, and pie, gantt and user-journey draw from the themes' colour palette instead of rendering monochrome. + +**Missing variables.** The colour themes were forked from the base themes by copy-paste and had drifted. `redux-color` was missing `stateEdgeLabelBackground` and `requirementEdgeLabelBackground` entirely, and silently re-derived `primaryBorderColor`, `clusterBkg`, `clusterBorder`, `altBackground` and `compositeTitleBackground` from the grey `primaryColor` instead of the tuned values in `redux`. `redux-dark-color` was missing `compositeBackground`, `altBackground`, `compositeTitleBackground`, `stateEdgeLabelBackground` and `requirementEdgeLabelBackground`. + +Visible effects: state and requirement diagram edge labels get a solid white (light) / `#16141F` (dark) backing plate instead of a grey box; flowchart, state and block subgraph containers get the `#F9F9FB` fill and `#BDBCCC` border; and borders derived from `primaryBorderColor` — gantt task borders, quadrant chart borders, C4 person borders, architecture group borders — get the dark `#28253D`-based border instead of light grey. + +**Monochrome chart diagrams.** Pie, gantt and user-journey read flat `pieN` / `fillTypeN` / `sectionBkgColor` variables rather than the `cScale` array, so they never picked up the colour themes' palette. Every value was a tint of a single pale lavender: `pie3` resolved to pure white in `redux-color` and to near-black in `redux-dark-color`, and both gantt section colours were white (light) or near-black (dark), so the section banding was invisible at the 20% opacity gantt paints it with. + +Now: pie slices are drawn from the theme's categorical scale, so a pie reads like a mindmap or treemap in the same theme; gantt section bands use two palette hues that survive the 20% opacity; and user-journey gets eight hue-distinct section fills — pale in the light theme, dark in the dark theme, since journey labels use the theme's `textColor`. + +The colour themes still differ from their base themes only on the palette: `borderColorArray`, `bkgColorArray`, the `cScale*` scale, and the pie/journey/gantt variables listed above. diff --git a/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts new file mode 100644 index 00000000000..39b52ba25b5 --- /dev/null +++ b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts @@ -0,0 +1,123 @@ +/** + * `redux-color` / `redux-dark-color` are the colour-carrying siblings of `redux` / + * `redux-dark`: they add `borderColorArray`, `bkgColorArray` and a real categorical + * palette on top of the same geometry and typography. + * + * They were forked by copy-paste, so they had drifted: seven variables `redux` + * defines were either missing (`stateEdgeLabelBackground`, + * `requirementEdgeLabelBackground`) or silently re-derived from the grey + * `primaryColor` (`primaryBorderColor`, `clusterBkg`, `clusterBorder`, + * `altBackground`, `compositeTitleBackground`). Nothing crashes when that + * happens — diagrams just pick up an untuned value where the base theme has a + * deliberate one, which is invisible in review and only shows up in a screenshot. + * + * This pins two properties: + * + * 1. A colour theme defines everything its base theme defines. + * 2. It diverges *only* on the palette it exists to provide — and that palette is + * listed here explicitly, so widening it is a deliberate edit to this file + * rather than something a loose pattern lets through unnoticed. + */ +// @ts-ignore TODO: incorrect types from khroma -- `isDark` exists at runtime but is +// missing from the shipped .d.ts, the same gap worked around in er/styles.ts. +import { isDark } from 'khroma'; +import { describe, expect, it } from 'vitest'; +import themes from './index.js'; + +/** + * The variables a colour theme is meant to own. Everything else has to match its + * base theme exactly. + */ +const PALETTE_VARS = new Set([ + // Categorical scale: mindmap, kanban, treemap, radar, timeline. + ...Array.from({ length: 12 }, (_, i) => `cScale${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScaleInv${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScalePeer${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScaleLabel${i}`), + // Pie slices are drawn from the categorical scale. + ...Array.from({ length: 12 }, (_, i) => `pie${i + 1}`), + // …which means the in-slice label ink is a palette decision too. + 'pieSectionTextColor', + // User-journey task and section fills. + ...Array.from({ length: 8 }, (_, i) => `fillType${i}`), + // Gantt section banding. + 'sectionBkgColor', + 'sectionBkgColor2', +]); + +const PAIRS = [ + ['redux', 'redux-color'], + ['redux-dark', 'redux-dark-color'], +] as const; + +describe.each(PAIRS)('%s -> %s', (baseName, colorName) => { + const base = themes[baseName].getThemeVariables({}) as unknown as Record; + const color = themes[colorName].getThemeVariables({}) as unknown as Record; + + it(`${colorName} defines every variable ${baseName} defines`, () => { + const missing = Object.keys(base).filter( + (key) => typeof base[key] !== 'function' && color[key] === undefined + ); + expect(missing).toEqual([]); + }); + + it(`${colorName} only diverges from ${baseName} on the palette`, () => { + const unexpected = Object.keys(base).filter( + (key) => + typeof base[key] !== 'function' && + !PALETTE_VARS.has(key) && + JSON.stringify(base[key]) !== JSON.stringify(color[key]) + ); + expect(unexpected).toEqual([]); + }); + + it(`${colorName} provides the colour arrays ${baseName} does not`, () => { + expect(base.borderColorArray).toBeUndefined(); + expect(color.borderColorArray).toHaveLength(12); + }); +}); + +/** + * The chart diagrams read flat `pieN` / `fillTypeN` variables rather than the + * `cScale` array, so they were the last diagrams still rendering monochrome under + * the colour themes: every `pieN` was a tint of one pale lavender (`pie3` resolved + * to pure white in `redux-color`, and to near-black in `redux-dark-color`). + * + * "12 distinct values" alone would have passed before this was fixed — the tints + * *were* distinct, just indistinguishable. So assert real separation instead. + */ +describe.each(['redux-color', 'redux-dark-color'] as const)('%s chart palettes', (name) => { + const vars = themes[name].getThemeVariables({}) as unknown as Record; + + it('draws pie slices from the categorical scale', () => { + const slices = Array.from({ length: 12 }, (_, i) => vars[`pie${i + 1}`]); + const scale = Array.from({ length: 12 }, (_, i) => vars[`cScale${i}`]); + expect(slices).toEqual(scale); + }); + + it('gives user-journey eight distinct task fills', () => { + const fills = Array.from({ length: 8 }, (_, i) => vars[`fillType${i}`]); + expect(fills.every((fill) => typeof fill === 'string' && fill.length > 0)).toBe(true); + expect(new Set(fills).size).toBe(8); + }); + + it('bands gantt sections with two different colours', () => { + expect(vars.sectionBkgColor).not.toBe(vars.sectionBkgColor2); + }); + + /** + * User-journey paints task labels with the theme's `textColor` (verified against the + * rendered DOM, not the stylesheet -- `user-journey/styles.js` also carries a + * hardcoded `.label text { fill: #333 }` rule that does not win). So a light theme + * needs light fills and a dark theme needs dark ones. Picking fills off the shared + * categorical scale gets this wrong in the dark theme: the labels end up light ink on + * a light fill. + */ + it('keeps user-journey fills on the opposite side of the label ink', () => { + const inkIsDark = isDark(vars.textColor); + const wrong = Array.from({ length: 8 }, (_, i) => `fillType${i}`).filter( + (key) => isDark(vars[key]) === inkIsDark + ); + expect(wrong).toEqual([]); + }); +}); diff --git a/packages/mermaid/src/themes/theme-redux-color.js b/packages/mermaid/src/themes/theme-redux-color.js index ca97f628016..ee966bdac7f 100644 --- a/packages/mermaid/src/themes/theme-redux-color.js +++ b/packages/mermaid/src/themes/theme-redux-color.js @@ -24,7 +24,7 @@ class Theme { this.radius = 12; this.strokeWidth = 2; - this.primaryBorderColor = mkBorder(this.primaryColor, this.darkMode); + this.primaryBorderColor = mkBorder('#28253D', this.darkMode); // dark this.fontFamily = '"Recursive Variable", arial, sans-serif'; @@ -40,6 +40,10 @@ class Theme { this.nodeShadow = true; this.tertiaryColor = '#ffffff'; + /* Class Diagram variables */ + this.clusterBkg = '#F9F9FB'; + this.clusterBorder = '#BDBCCC'; + /* Architecture Diagram variables */ this.archEdgeColor = 'calculated'; this.archEdgeArrowColor = 'calculated'; @@ -147,10 +151,13 @@ class Theme { const primaryColor = '#ECECFE'; const secondaryColor = '#E9E9F1'; const tertiaryColor = adjust(primaryColor, { h: 180, l: 5 }); - this.sectionBkgColor = this.sectionBkgColor || tertiaryColor; + // Section bands are painted at 20% opacity (gantt/styles.js `.section`), so the + // source colour has to be saturated to read at all -- the `primaryColor` tints this + // used before gave no banding. Literals rather than `cScale0`/`cScale1` because the + // categorical scale is not assigned until further down updateColors(). + this.sectionBkgColor = this.sectionBkgColor || '#f4a8ff'; // Fuchsia-300 this.altSectionBkgColor = this.altSectionBkgColor || 'white'; - this.sectionBkgColor = this.sectionBkgColor || secondaryColor; - this.sectionBkgColor2 = this.sectionBkgColor2 || primaryColor; + this.sectionBkgColor2 = this.sectionBkgColor2 || '#46ecd5'; // Teal-300 this.excludeBkgColor = this.excludeBkgColor || '#eeeeee'; this.taskBorderColor = this.taskBorderColor || this.primaryBorderColor; this.taskBkgColor = this.taskBkgColor || primaryColor; @@ -184,7 +191,9 @@ class Theme { this.transitionLabelColor = this.transitionLabelColor || this.textColor; /* The color of the text tables of the states*/ this.stateLabelColor = this.stateLabelColor || this.stateBkg || this.primaryTextColor; - + this.compositeTitleBackground = '#F9F9FB'; + this.altBackground = '#F9F9FB'; + this.stateEdgeLabelBackground = '#FFFFFF'; this.stateBkg = this.stateBkg || this.mainBkg; this.labelBackgroundColor = this.labelBackgroundColor || this.stateBkg; this.compositeBackground = this.compositeBackground || this.background || this.tertiaryColor; @@ -257,28 +266,20 @@ class Theme { this.classText = this.classText || this.textColor; /* user-journey */ - this.fillType0 = this.fillType0 || primaryColor; - this.fillType1 = this.fillType1 || secondaryColor; - this.fillType2 = this.fillType2 || adjust(primaryColor, { h: 64 }); - this.fillType3 = this.fillType3 || adjust(secondaryColor, { h: 64 }); - this.fillType4 = this.fillType4 || adjust(primaryColor, { h: -64 }); - this.fillType5 = this.fillType5 || adjust(secondaryColor, { h: -64 }); - this.fillType6 = this.fillType6 || adjust(primaryColor, { h: 128 }); - this.fillType7 = this.fillType7 || adjust(secondaryColor, { h: 128 }); + // Journey task labels are hardcoded to #333 in user-journey/styles.js, so these + // fills must stay light. The pale background array keeps them hue-distinct where + // the old tints of `primaryColor` were eight shades of the same lavender. + for (let i = 0; i < 8; i++) { + this['fillType' + i] = this['fillType' + i] || this.bkgColorArray[i]; + } /* pie */ - this.pie1 = this.pie1 || primaryColor; - this.pie2 = this.pie2 || secondaryColor; - this.pie3 = this.pie3 || tertiaryColor; - this.pie4 = this.pie4 || adjust(primaryColor, { l: -10 }); - this.pie5 = this.pie5 || adjust(secondaryColor, { l: -10 }); - this.pie6 = this.pie6 || adjust(tertiaryColor, { l: -10 }); - this.pie7 = this.pie7 || adjust(primaryColor, { h: +60, l: -10 }); - this.pie8 = this.pie8 || adjust(primaryColor, { h: -60, l: -10 }); - this.pie9 = this.pie9 || adjust(primaryColor, { h: 120, l: 0 }); - this.pie10 = this.pie10 || adjust(primaryColor, { h: +60, l: -20 }); - this.pie11 = this.pie11 || adjust(primaryColor, { h: -60, l: -20 }); - this.pie12 = this.pie12 || adjust(primaryColor, { h: 120, l: -10 }); + // Slices reuse the theme's categorical scale so a pie reads like a mindmap or + // treemap in the same theme. The old tints of `primaryColor` were all near-white + // (pie3 resolved to pure white), leaving adjacent slices indistinguishable. + for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { + this['pie' + (i + 1)] = this['pie' + (i + 1)] || this['cScale' + i]; + } this.pieTitleTextSize = this.pieTitleTextSize || '25px'; this.pieTitleTextColor = this.pieTitleTextColor || this.taskTextDarkColor; this.pieSectionTextSize = this.pieSectionTextSize || '17px'; @@ -348,6 +349,7 @@ class Theme { this.relationLabelBackground || (this.darkMode ? darken(this.secondaryColor, 30) : this.secondaryColor); this.relationLabelColor = this.relationLabelColor || this.actorTextColor; + this.requirementEdgeLabelBackground = '#FFFFFF'; /* git */ this.git0 = this.git0 || primaryColor; diff --git a/packages/mermaid/src/themes/theme-redux-dark-color.js b/packages/mermaid/src/themes/theme-redux-dark-color.js index c3a071c7d4e..4991ae9f736 100644 --- a/packages/mermaid/src/themes/theme-redux-dark-color.js +++ b/packages/mermaid/src/themes/theme-redux-dark-color.js @@ -156,10 +156,13 @@ class Theme { /* Gantt chart variables */ - this.sectionBkgColor = this.sectionBkgColor || this.tertiaryColor; + // Section bands are painted at 20% opacity (gantt/styles.js `.section`), so the + // source colour has to be saturated to read at all -- the `primaryColor` tints this + // used before gave no banding. Literals rather than `cScale0`/`cScale1` because the + // categorical scale is not assigned until further down updateColors(). + this.sectionBkgColor = this.sectionBkgColor || '#f4a8ff'; // Fuchsia-300 this.altSectionBkgColor = this.altSectionBkgColor || 'white'; - this.sectionBkgColor = this.sectionBkgColor || this.secondaryColor; - this.sectionBkgColor2 = this.sectionBkgColor2 || this.primaryColor; + this.sectionBkgColor2 = this.sectionBkgColor2 || '#46ecd5'; // Teal-300 this.excludeBkgColor = this.excludeBkgColor || '#eeeeee'; this.taskBorderColor = this.taskBorderColor || this.primaryBorderColor; this.taskBkgColor = this.taskBkgColor || this.primaryColor; @@ -193,7 +196,10 @@ class Theme { this.transitionLabelColor = this.transitionLabelColor || this.textColor; /* The color of the text tables of the states*/ this.stateLabelColor = this.stateLabelColor || this.stateBkg || this.primaryTextColor; - + this.compositeBackground = '#16141F'; + this.altBackground = '#16141F'; + this.compositeTitleBackground = '#16141F'; + this.stateEdgeLabelBackground = '#16141F'; this.stateBkg = this.stateBkg || this.mainBkg; this.labelBackgroundColor = this.labelBackgroundColor || this.stateBkg; this.compositeBackground = this.compositeBackground || this.background || this.tertiaryColor; @@ -265,32 +271,41 @@ class Theme { this.classText = this.classText || this.textColor; /* user-journey */ - this.fillType0 = this.fillType0 || this.primaryColor; - this.fillType1 = this.fillType1 || this.secondaryColor; - this.fillType2 = this.fillType2 || adjust(this.primaryColor, { h: 64 }); - this.fillType3 = this.fillType3 || adjust(this.secondaryColor, { h: 64 }); - this.fillType4 = this.fillType4 || adjust(this.primaryColor, { h: -64 }); - this.fillType5 = this.fillType5 || adjust(this.secondaryColor, { h: -64 }); - this.fillType6 = this.fillType6 || adjust(this.primaryColor, { h: 128 }); - this.fillType7 = this.fillType7 || adjust(this.secondaryColor, { h: 128 }); + // Journey paints the section band and the task rects inside it with the same + // `fillTypeN`, and task labels use the light `textColor`, so these fills have to + // stay dark -- a saturated fill collapses band and task into one block and swamps + // the labels. `bkgColorArray` is empty in this theme, so there is no pale array to + // draw on, and darkening the categorical scale by a fixed amount does not work + // either: the 300-shades differ too much in luminance, so teal lands near black + // while fuchsia stays clearly purple. Fixed 900-shades of the same hue order give + // even weight across the eight sections. + const journeyFills = [ + '#701a75', // Fuchsia-900 + '#134e4a', // Teal-900 + '#7c2d12', // Orange-900 + '#581c87', // Purple-900 + '#14532d', // Green-900 + '#4c1d95', // Violet-900 + '#7f1d1d', // Red-900 + '#713f12', // Yellow-900 + ]; + journeyFills.forEach((fill, i) => { + this['fillType' + i] = this['fillType' + i] || fill; + }); /* pie */ - this.pie1 = this.pie1 || this.primaryColor; - this.pie2 = this.pie2 || this.secondaryColor; - this.pie3 = this.pie3 || this.tertiaryColor; - this.pie4 = this.pie4 || adjust(this.primaryColor, { l: -10 }); - this.pie5 = this.pie5 || adjust(this.secondaryColor, { l: -10 }); - this.pie6 = this.pie6 || adjust(this.tertiaryColor, { l: -10 }); - this.pie7 = this.pie7 || adjust(this.primaryColor, { h: +60, l: -10 }); - this.pie8 = this.pie8 || adjust(this.primaryColor, { h: -60, l: -10 }); - this.pie9 = this.pie9 || adjust(this.primaryColor, { h: 120, l: 0 }); - this.pie10 = this.pie10 || adjust(this.primaryColor, { h: +60, l: -20 }); - this.pie11 = this.pie11 || adjust(this.primaryColor, { h: -60, l: -20 }); - this.pie12 = this.pie12 || adjust(this.primaryColor, { h: 120, l: -10 }); + // Slices reuse the theme's categorical scale so a pie reads like a mindmap or + // treemap in the same theme. The old tints of `primaryColor` were all near-black + // on a dark canvas, leaving adjacent slices indistinguishable. + for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { + this['pie' + (i + 1)] = this['pie' + (i + 1)] || this['cScale' + i]; + } this.pieTitleTextSize = this.pieTitleTextSize || '25px'; this.pieTitleTextColor = this.pieTitleTextColor || this.taskTextDarkColor; this.pieSectionTextSize = this.pieSectionTextSize || '17px'; - this.pieSectionTextColor = this.pieSectionTextColor || this.textColor; + // Slice fills are now the bright categorical scale, so the in-slice label needs a + // dark ink rather than the theme's light `textColor`. + this.pieSectionTextColor = this.pieSectionTextColor || '#28253D'; this.pieLegendTextSize = this.pieLegendTextSize || '17px'; this.pieLegendTextColor = this.pieLegendTextColor || this.taskTextDarkColor; this.pieStrokeColor = this.pieStrokeColor || 'black'; @@ -356,6 +371,7 @@ class Theme { this.relationLabelBackground || (this.darkMode ? darken(this.secondaryColor, 30) : this.secondaryColor); this.relationLabelColor = this.relationLabelColor || this.actorTextColor; + this.requirementEdgeLabelBackground = '#16141F'; /* git */ this.git0 = this.git0 || this.primaryColor; From d57ed55a5482517927e5e4f4a9d0889078f4a902 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Thu, 27 Aug 2026 15:30:17 +0200 Subject: [PATCH 04/31] fix(docs): stop docs:build deleting docs/ when a later step fails `docs:build` ran `rimraf ../../docs` as its *first* step: rimraf ../../docs && docs:code && docs:spellcheck && docs.cli.mts so a failure in typedoc or cspell left the whole committed `docs/` tree deleted and never regenerated. The contributor got ~150 staged deletions with no obvious cause, and -- because `.lintstagedrc.mjs` wires `docs:build` to any change under `src/docs/**` -- a pre-commit hook that could not succeed until they worked out what had happened and restored the directory by hand. The deletion is still required so that pages whose source was removed do not linger, so it now runs immediately before the step that regenerates the directory, after the two steps that can realistically fail: docs:code && docs:spellcheck && rimraf ../../docs && docs.cli.mts Verified by appending an unrecognised word to a file under `src/docs/` and running `docs:build`: it exits 1 at the spellcheck step with all 155 files in `docs/` intact. Before this change the same failure removed every one of them. `docs:verify` already used this order and is unchanged. `docs:pre:vitepress` has the same shape but targets `src/vitepress`, which is gitignored scratch, so a failure there costs nothing and it is left alone. --- .../fix-docs-build-destructive-order.md | 9 ++++ packages/mermaid/package.json | 2 +- .../mermaid/scripts/docsBuildOrder.spec.ts | 51 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-docs-build-destructive-order.md create mode 100644 packages/mermaid/scripts/docsBuildOrder.spec.ts diff --git a/.changeset/fix-docs-build-destructive-order.md b/.changeset/fix-docs-build-destructive-order.md new file mode 100644 index 00000000000..86485a75b29 --- /dev/null +++ b/.changeset/fix-docs-build-destructive-order.md @@ -0,0 +1,9 @@ +--- +'mermaid': patch +--- + +fix(docs): stop `docs:build` deleting the committed `docs/` directory when a later step fails. + +`docs:build` ran `rimraf ../../docs` as its first step, before `docs:code` (typedoc) and `docs:spellcheck`. A failure in either left the whole committed `docs/` tree deleted and never regenerated, handing the contributor ~150 staged deletions with no obvious cause — and a pre-commit hook that could not succeed, since `docs:build` is wired to any change under `src/docs/**`. + +The deletion is still needed so that pages whose source was removed do not linger, so it now runs immediately before the step that regenerates the directory, after the two steps that can realistically fail. diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index 8569fe06042..0aa319e7550 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -36,7 +36,7 @@ "clean": "rimraf dist", "dev": "pnpm -w dev", "docs:code": "typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup", - "docs:build": "rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts", + "docs:build": "pnpm docs:code && pnpm docs:spellcheck && rimraf ../../docs && tsx scripts/docs.cli.mts", "docs:verify": "pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify", "docs:pre:vitepress": "pnpm --filter ./src/docs prefetch && rimraf src/vitepress && pnpm docs:code && tsx scripts/docs.cli.mts --vitepress && pnpm --filter ./src/vitepress install --no-frozen-lockfile --ignore-scripts", "docs:build:vitepress": "pnpm docs:pre:vitepress && (cd src/vitepress && pnpm run build) && cpy --flat src/docs/landing/ ./src/vitepress/.vitepress/dist/landing", diff --git a/packages/mermaid/scripts/docsBuildOrder.spec.ts b/packages/mermaid/scripts/docsBuildOrder.spec.ts new file mode 100644 index 00000000000..7277c8142e2 --- /dev/null +++ b/packages/mermaid/scripts/docsBuildOrder.spec.ts @@ -0,0 +1,51 @@ +/** + * `docs:build` regenerates the committed `docs/` directory, and it has to delete the old + * contents first so that pages whose source was removed do not linger. + * + * The order of those steps matters. `rimraf ../../docs` used to run *first*, before + * `docs:code` (typedoc) and `docs:spellcheck` — so any failure in either left the whole + * committed `docs/` tree deleted and never regenerated. A contributor whose typedoc run + * errored, or who added one unrecognised word to a doc, was handed ~150 staged deletions + * with no obvious cause. + * + * This is a property of a package.json script rather than of any module, so there is + * nothing else to hang a test on — but it is exactly the kind of thing that gets + * reintroduced by someone tidying the script into what looks like a more natural order + * (clean, then build). + */ +import { describe, expect, it } from 'vitest'; +import packageJson from '../package.json' with { type: 'json' }; + +const stepsOf = (script: string) => script.split('&&').map((step) => step.trim()); + +const indexOfStep = (steps: string[], needle: string) => + steps.findIndex((step) => step.includes(needle)); + +describe('docs:build step order', () => { + const script: string = packageJson.scripts['docs:build']; + const steps = stepsOf(script); + + it('still deletes the docs directory, so removed pages do not linger', () => { + expect(indexOfStep(steps, 'rimraf ../../docs')).toBeGreaterThanOrEqual(0); + }); + + it('regenerates the docs directory in the same run', () => { + expect(indexOfStep(steps, 'docs.cli.mts')).toBeGreaterThanOrEqual(0); + }); + + it.each(['docs:code', 'docs:spellcheck'])( + 'runs %s before deleting the docs directory', + (fallibleStep) => { + const deleteAt = indexOfStep(steps, 'rimraf ../../docs'); + const stepAt = indexOfStep(steps, fallibleStep); + expect(stepAt).toBeGreaterThanOrEqual(0); + expect(stepAt).toBeLessThan(deleteAt); + } + ); + + it('deletes the docs directory immediately before regenerating it', () => { + const deleteAt = indexOfStep(steps, 'rimraf ../../docs'); + const generateAt = indexOfStep(steps, 'docs.cli.mts'); + expect(generateAt).toBe(deleteAt + 1); + }); +}); From 7ee4c3fb089f3a828f0213c0aa090ed5cacc310f Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 16:57:46 +0200 Subject: [PATCH 05/31] fix(rendering): drop the half-pixel bias in intersectLine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `intersectLine` comes from Graphics Gems, where the coordinates were INTEGERS and `denom / 2` was added to the numerator so the integer division rounded instead of truncating. JavaScript division does neither, so that term was not a rounding correction at all: `(num + denom / 2) / denom` is `num / denom + 0.5` — a constant half-unit displacement of every intersection, on both axes. `intersectPolygon` is how every non-rectangular shape finds its edge attachment — diamond, stadium, hexagon, trapezoid, subroutine. Half a pixel is invisible alone, but it moved the point off the axis the query ray travelled, which is enough to give an otherwise orthogonal edge a tiny diagonal opening segment and to push an attachment just inside the node it was meant to touch. `question.ts` had been compensating by subtracting 0.5 back off for diamonds; that compensation goes with it, or the attachment moves half a pixel the other way. --- .changeset/intersect-line-half-pixel.md | 11 +++ .../intersect/intersect-line.js | 25 ++++--- .../intersect/intersect-line.spec.ts | 74 +++++++++++++++++++ .../rendering-elements/shapes/question.ts | 12 ++- 4 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 .changeset/intersect-line-half-pixel.md create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts diff --git a/.changeset/intersect-line-half-pixel.md b/.changeset/intersect-line-half-pixel.md new file mode 100644 index 00000000000..dbe4b8d640a --- /dev/null +++ b/.changeset/intersect-line-half-pixel.md @@ -0,0 +1,11 @@ +--- +'mermaid': patch +--- + +fix: edges attach to non-rectangular shapes on the outline instead of half a pixel off it. + +`intersectLine` comes from Graphics Gems, where the coordinates were integers and `denom / 2` was added to the numerator so the integer division rounded instead of truncating. JavaScript division does neither, so the term was never a rounding correction: `(num + denom / 2) / denom` is `num / denom + 0.5`. Every intersection came back displaced half a unit on both axes. + +`intersectPolygon` is how every non-rectangular shape finds its edge attachment — diamond, stadium, hexagon, trapezoid, subroutine — so a vertical ray leaving a node's bottom border returned a point half a pixel to the right of it and half a pixel below it. Enough to give an otherwise orthogonal edge a tiny diagonal opening segment, and, when the ray pointed the other way, to put the attachment just inside the node it was meant to touch. + +`question.ts` had been subtracting the 0.5 back off for diamonds; that compensation is removed along with the cause. **Rendered output moves by half a pixel wherever a polygon shape terminates an edge.** diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js index 6d476fac9ad..94e033ca529 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js @@ -46,16 +46,21 @@ function intersectLine(p1, p2, q1, q2) { return /*COLLINEAR*/; } - const offset = Math.abs(denom / 2); - - // The denom/2 is to get rounding instead of truncating. It - // is added or subtracted to the numerator, depending upon the - // sign of the numerator. - let num = b1 * c2 - b2 * c1; - const x = num < 0 ? (num - offset) / denom : (num + offset) / denom; - - num = a2 * c1 - a1 * c2; - const y = num < 0 ? (num - offset) / denom : (num + offset) / denom; + // The Graphics Gems original added `denom / 2` to the numerator here so + // that an INTEGER division would round rather than truncate. JavaScript + // division does neither, so that term was not a rounding correction at all: + // `(num + denom / 2) / denom` is `num / denom + 0.5`, a constant half-unit + // displacement of every intersection, on both axes. + // + // Half a pixel is invisible by itself, but `intersectPolygon` is how every + // non-rectangular shape finds its edge attachment, so it moved the point off + // the axis the query ray travelled along — enough to give an otherwise + // orthogonal edge a tiny diagonal opening segment, and to push an + // attachment just inside the node it was meant to touch. `question.ts` used + // to subtract the 0.5 back off for diamonds; that compensation went away + // with this. + const x = (b1 * c2 - b2 * c1) / denom; + const y = (a2 * c1 - a1 * c2) / denom; return { x: x, y: y }; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts new file mode 100644 index 00000000000..81d9837aab9 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import intersectLine from './intersect-line.js'; +import intersectPolygon from './intersect-polygon.js'; + +/** + * `intersectLine` comes from Graphics Gems, where the coordinates were INTEGERS + * and `denom / 2` was added to the numerator so that the integer division + * rounded instead of truncating. JavaScript has no truncating division, so that + * term stopped being a rounding correction and became a constant half-unit + * displacement of every result. + * + * It matters because `intersectPolygon` is how every non-rectangular shape + * computes its edge attachment — diamond, stadium, hexagon, trapezoid, + * subroutine. Half a pixel is invisible on its own, but it puts the attachment + * point off the axis it was supposed to sit on, which turns an orthogonal edge + * into one with a tiny diagonal opening segment, and can place the point just + * inside the node it is supposed to touch. + */ +describe('intersectLine', () => { + it('intersects a vertical line with a horizontal segment exactly at the crossing', () => { + // Vertical line x = 10, horizontal segment y = 20. + const result = intersectLine( + { x: 10, y: 0 }, + { x: 10, y: 100 }, + { x: 0, y: 20 }, + { x: 50, y: 20 } + ); + + expect(result).toEqual({ x: 10, y: 20 }); + }); + + it('intersects a horizontal line with a vertical segment exactly at the crossing', () => { + const result = intersectLine( + { x: 0, y: 7 }, + { x: 100, y: 7 }, + { x: 33, y: 0 }, + { x: 33, y: 50 } + ); + + expect(result).toEqual({ x: 33, y: 7 }); + }); + + it('keeps the crossing on the query line for non-integer coordinates', () => { + // The case that shows up in real layouts: node centres are fractional, and + // the attachment must stay on the vertical ray leaving the node. + const x = 285.01588439941406; + // `intersectLine` returns undefined when the segments miss each other. + const result = intersectLine({ x, y: 34.5 }, { x, y: 67 }, { x: 0, y: 57 }, { x: 400, y: 57 }); + expect(result).toBeDefined(); + + expect(result!.x).toBeCloseTo(x, 9); + expect(result!.y).toBeCloseTo(57, 9); + }); +}); + +describe('intersectPolygon', () => { + it('attaches on the outline, on the axis the query ray travelled', () => { + // A diamond, 100x100, centred at (200, 200): vertices at the midpoints of + // its bounding box sides. A ray leaving the centre due east must attach at + // the east vertex exactly, not half a pixel off it in both axes. + const node = { x: 200, y: 200, width: 100, height: 100 }; + const points = [ + { x: 0, y: -50 }, + { x: 50, y: 0 }, + { x: 0, y: 50 }, + { x: -50, y: 0 }, + ]; + + const result = intersectPolygon(node, points, { x: 400, y: 200 }); + + expect(result.x).toBeCloseTo(250, 9); + expect(result.y).toBeCloseTo(200, 9); + }); +}); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts index 988d0794782..9cd68c28848 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts @@ -72,10 +72,14 @@ export async function question(parent: D3Selection { x: 0, y: -s / 2 }, ]; - // Calculate the intersection point - const res = intersect.polygon(bounds, points, point); - - return { x: res.x - 0.5, y: res.y - 0.5 }; // Adjusted result + // Calculate the intersection point. + // + // This used to return `res` shifted by -0.5 on both axes, compensating for + // a half-unit displacement that `intersectLine` applied to every result — + // leftover integer-rounding arithmetic from the Graphics Gems original. The + // displacement is gone, so the compensation has to go with it or the + // diamond's attachment moves half a pixel the other way. + return intersect.polygon(bounds, points, point); }; node.intersect = function (point) { From d4bea0d33944ef8941daba8fab32fb144dde80da Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 16:58:01 +0200 Subject: [PATCH 06/31] feat(elk): name the node-placement combinations as elk.preset The ELK options that matter most come in combinations that only make sense together, and "which layering strategy goes with which node placement" is not something a diagram author should have to know. `elk.preset` names three: `default`, `legacy` (what shipped before this branch) and `depthFirst`. An explicit `elk.layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` beats the preset for that one option, so a preset is a starting point rather than a lock. The individual keys default to `undefined` rather than to a value, which is what makes `config.elk?.X ?? preset.Y` fall through to the preset. Note that `defaultConfig.ts` is hand-written: a `default:` in the schema alone never reaches `config.elk`. --- .changeset/elk-layout-presets.md | 26 ++++++ packages/mermaid/src/config.type.ts | 77 ++++++++++++++++ packages/mermaid/src/defaultConfig.ts | 13 ++- .../mermaid/src/schemas/config.schema.yaml | 89 ++++++++++++++++++- 4 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 .changeset/elk-layout-presets.md diff --git a/.changeset/elk-layout-presets.md b/.changeset/elk-layout-presets.md new file mode 100644 index 00000000000..db21c7f87b4 --- /dev/null +++ b/.changeset/elk-layout-presets.md @@ -0,0 +1,26 @@ +--- +'@mermaid-js/layout-elk': minor +--- + +feat: `elk.preset` picks a named combination of the options that decide where nodes end up. + +Three options settle node positions, and they sit in different phases of the layout: which layer a node lands in, where it goes within that layer, and which edges get reversed to make the graph acyclic. Choosing them well means knowing all three interact; `preset` names the combinations worth using. + +- `default` — network simplex layering, linear segments placement, greedy model order cycle breaking. Keeps chains of nodes aligned. +- `legacy` — reproduces what earlier versions actually rendered: Brandes-Koepf placement with ELK's own greedy cycle breaking. +- `depthFirst` — as `default`, but breaks cycles depth first, which gives shorter back edges on graphs that have many. + +```yaml +--- +config: + layout: elk + elk: + preset: legacy +--- +``` + +Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one option and leaves the rest in place, so a preset is a starting point rather than a lock. + +**The default placement strategy changes from `NETWORK_SIMPLEX` to `LINEAR_SEGMENTS`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour. + +Note that `legacy` uses `GREEDY` cycle breaking rather than the `GREEDY_MODEL_ORDER` the schema previously advertised. That default was declared in the schema but never listed in the shipped defaults, so it reached ELK as undefined and ELK's own default applied — `legacy` reproduces what was rendered, not what was documented. diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 6141e7db15c..8c850bc88ed 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -125,6 +125,83 @@ export interface MermaidConfig { * */ nodePlacementAlignment?: 'NONE' | 'LEFTUP' | 'LEFTDOWN' | 'RIGHTUP' | 'RIGHTDOWN' | 'BALANCED'; + /** + * Named combination of the three options that decide where nodes end up: + * layering strategy, node placement strategy and cycle breaking strategy. + * They belong to different phases of the layout, so a preset is simply a + * named triple rather than a mode with behaviour of its own. + * + * `default` — network simplex layering, linear segments placement, greedy + * model order cycle breaking. Keeps chains of nodes aligned. + * + * `legacy` — what shipped before presets existed: Brandes-Koepf placement, + * which straightens long edges at the cost of that alignment, with ELK's + * own greedy cycle breaking. Reproduces the rendering of earlier + * versions rather than the defaults their schema advertised. + * + * `depthFirst` — as `default`, but breaks cycles depth first, which tends + * to give shorter back edges on graphs that have many of them. + * + * Setting `layeringStrategy`, `nodePlacementStrategy` or + * `cycleBreakingStrategy` explicitly overrides the preset for that one + * option; the rest of the preset still applies. + * + */ + preset?: 'default' | 'legacy' | 'depthFirst'; + /** + * Straightens an edge that leaves or enters a node with a tiny step. + * + * ELK spreads an edge's port evenly along a node's side but routes the + * edge down a channel whose row rarely lines up with that port exactly, + * leaving a staircase of a few pixels right at the border. With rounded + * corners the two micro-bends land on top of each other and read as a + * kink. Enabling this moves the endpoint onto the channel row — still on + * the node's border — and drops the step. + * + * Only the step next to a node is touched, and only when the edge + * continues the same way afterwards, so a real turn is never collapsed. + * + */ + straightenEdges?: boolean; + /** + * Renders edge crossings as small arcs ("hops") or visible gaps, so that + * it is clear which line passes over which where two edges meet. + * + * The edge that gives way loses its corner rounding for the segment + * carrying the hop, which is the trade for a readable crossing. Curved + * edges are skipped rather than rewritten, to avoid corrupting their + * geometry. Set to `false` to draw plain crossings. + * + */ + lineHops?: boolean | ('arc' | 'gap'); + /** + * Elk specific option deciding which layer each node is assigned to — the + * column in a left-to-right diagram, the row in a top-down one. This is + * the coarsest of the three placement decisions, so changing it moves + * nodes further than anything else short of altering spacing. + * + * NETWORK_SIMPLEX aims for the fewest long edges. LONGEST_PATH pushes + * every node as late as it can go. COFFMAN_GRAHAM bounds how many nodes + * share a layer, giving a more even, block-like shape on wide graphs. + * MIN_WIDTH and STRETCH_WIDTH trade edge length for a narrower or wider + * drawing. INTERACTIVE honours positions already on the nodes. + * + */ + layeringStrategy?: + | 'NETWORK_SIMPLEX' + | 'LONGEST_PATH' + | 'LONGEST_PATH_SOURCE' + | 'COFFMAN_GRAHAM' + | 'MIN_WIDTH' + | 'STRETCH_WIDTH' + | 'INTERACTIVE'; + /** + * Elk specific option capping how many nodes COFFMAN_GRAHAM will put in + * one layer. Ignored by every other layering strategy. Lower values give + * a taller, narrower drawing. + * + */ + layeringLayerBound?: number; /** * This strategy decides how to find cycles in the graph and deciding which edges need adjustment to break loops. * diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 208fee3534f..c52397f218b 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -23,8 +23,19 @@ const config: RequiredDeep = { elk: { // mergeEdges is needed here to be considered mergeEdges: false, - nodePlacementStrategy: 'BRANDES_KOEPF', + straightenEdges: true, + lineHops: true, + preset: 'default', + // Left undefined so `??` can tell "the user chose this" from "nobody did", + // which is what lets `elk.preset` supply a value while an explicit setting + // still wins. Listed rather than omitted so `configKeys` still finds them. + nodePlacementStrategy: undefined, + layeringStrategy: undefined, + cycleBreakingStrategy: undefined, + layeringLayerBound: 4, + // Brandes-Koepf specific; inert unless nodePlacementStrategy is set back to it. nodePlacementAlignment: 'NONE', + forceNodeModelOrder: false, considerModelOrder: 'NODES_AND_EDGES', keepEntryNodeOnTop: false, diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index f700b60f732..647633f68e4 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -132,7 +132,7 @@ properties: - NETWORK_SIMPLEX - LINEAR_SEGMENTS - BRANDES_KOEPF - default: BRANDES_KOEPF + # Default comes from `elk.preset` unless set explicitly. nodePlacementAlignment: description: | Elk specific option affecting Brandes-Koepf node placement alignment. @@ -146,6 +146,91 @@ properties: - RIGHTDOWN - BALANCED default: NONE + preset: + description: | + Named combination of the three options that decide where nodes end up: + layering strategy, node placement strategy and cycle breaking strategy. + They belong to different phases of the layout, so a preset is simply a + named triple rather than a mode with behaviour of its own. + + `default` — network simplex layering, linear segments placement, greedy + model order cycle breaking. Keeps chains of nodes aligned. + + `legacy` — what shipped before presets existed: Brandes-Koepf placement, + which straightens long edges at the cost of that alignment, with ELK's + own greedy cycle breaking. Reproduces the rendering of earlier + versions rather than the defaults their schema advertised. + + `depthFirst` — as `default`, but breaks cycles depth first, which tends + to give shorter back edges on graphs that have many of them. + + Setting `layeringStrategy`, `nodePlacementStrategy` or + `cycleBreakingStrategy` explicitly overrides the preset for that one + option; the rest of the preset still applies. + type: string + enum: + - default + - legacy + - depthFirst + default: default + straightenEdges: + description: | + Straightens an edge that leaves or enters a node with a tiny step. + + ELK spreads an edge's port evenly along a node's side but routes the + edge down a channel whose row rarely lines up with that port exactly, + leaving a staircase of a few pixels right at the border. With rounded + corners the two micro-bends land on top of each other and read as a + kink. Enabling this moves the endpoint onto the channel row — still on + the node's border — and drops the step. + + Only the step next to a node is touched, and only when the edge + continues the same way afterwards, so a real turn is never collapsed. + type: boolean + default: true + lineHops: + description: | + Renders edge crossings as small arcs ("hops") or visible gaps, so that + it is clear which line passes over which where two edges meet. + + The edge that gives way loses its corner rounding for the segment + carrying the hop, which is the trade for a readable crossing. Curved + edges are skipped rather than rewritten, to avoid corrupting their + geometry. Set to `false` to draw plain crossings. + oneOf: + - type: boolean + - type: string + enum: ['arc', 'gap'] + default: true + layeringStrategy: + description: | + Elk specific option deciding which layer each node is assigned to — the + column in a left-to-right diagram, the row in a top-down one. This is + the coarsest of the three placement decisions, so changing it moves + nodes further than anything else short of altering spacing. + + NETWORK_SIMPLEX aims for the fewest long edges. LONGEST_PATH pushes + every node as late as it can go. COFFMAN_GRAHAM bounds how many nodes + share a layer, giving a more even, block-like shape on wide graphs. + MIN_WIDTH and STRETCH_WIDTH trade edge length for a narrower or wider + drawing. INTERACTIVE honours positions already on the nodes. + type: string + enum: + - NETWORK_SIMPLEX + - LONGEST_PATH + - LONGEST_PATH_SOURCE + - COFFMAN_GRAHAM + - MIN_WIDTH + - STRETCH_WIDTH + - INTERACTIVE + # Default comes from `elk.preset` unless set explicitly. + layeringLayerBound: + description: | + Elk specific option capping how many nodes COFFMAN_GRAHAM will put in + one layer. Ignored by every other layering strategy. Lower values give + a taller, narrower drawing. + type: number + default: 4 cycleBreakingStrategy: description: | This strategy decides how to find cycles in the graph and deciding which edges need adjustment to break loops. @@ -156,7 +241,7 @@ properties: - INTERACTIVE - MODEL_ORDER - GREEDY_MODEL_ORDER - default: GREEDY_MODEL_ORDER + # Default comes from `elk.preset` unless set explicitly. forceNodeModelOrder: description: | The node order given by the model does not change to produce a better layout. E.g. if node A is before node B in the model this is not changed during crossing minimization. This assumes that the node model order is already respected before crossing minimization. This can be achieved by setting considerModelOrder.strategy to NODES_AND_EDGES. From 1246a55fd371fd5a8ed40f617be676b08e57645f Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 16:58:01 +0200 Subject: [PATCH 07/31] feat(elk): draw line hops where edges cross MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where two edges cross, the one that gives way is drawn with a small arc (or a gap) so it is clear which line passes over which. On by default; `elk.lineHops: false` for plain crossings, `'gap'` for gaps. Detection and both styles already existed for swimlanes. What is new is the `afterPaint` hook that lets ELK use them, and `applyLineJumpsToSvg` being exported so a layout package outside `mermaid` can reach it. Two defects in the hop geometry are fixed here, both found on real diagrams and both of which rendered as something that looked broken rather than merely untidy: - A hop with no room next to a bend was fitted into whatever was left, as little as 2.9px against a requested 6, opening exactly on the corner's tangent point. At that radius the arc does not clear the stroke it is hopping, so the lines still touch. Hops now keep a straight run clear of the bend, and one that would still shrink below 60% of the requested radius is dropped — an ordinary crossing is a much better failure than a broken-looking hop. - A crossing found inside the stretch where either edge is rounding a bend is now ignored. Crossings are computed on polylines, but a rounded edge is not drawn as its polyline: it leaves the line up to 7.07px before each bend and rejoins it that far after. A crossing found in there is somewhere the stroke never goes, so the arc arched over blank paper while the two lines carried on touching beside it. --- .changeset/elk-line-hops.md | 20 +++ .changeset/export-line-jumps.md | 9 + .changeset/line-hop-corner-clearance.md | 13 ++ packages/mermaid-layout-elk/src/lineHops.ts | 52 ++++++ packages/mermaid/src/mermaid.ts | 5 + .../rendering-elements/lineJump.spec.ts | 165 ++++++++++++++++++ .../rendering-elements/lineJump.ts | 96 +++++++++- 7 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 .changeset/elk-line-hops.md create mode 100644 .changeset/export-line-jumps.md create mode 100644 .changeset/line-hop-corner-clearance.md create mode 100644 packages/mermaid-layout-elk/src/lineHops.ts diff --git a/.changeset/elk-line-hops.md b/.changeset/elk-line-hops.md new file mode 100644 index 00000000000..f0fbfa3a48e --- /dev/null +++ b/.changeset/elk-line-hops.md @@ -0,0 +1,20 @@ +--- +'@mermaid-js/layout-elk': minor +--- + +feat: draw line hops where ELK edges cross, controlled by `elk.lineHops`. + +Where two edges cross, the one that gives way is drawn with a small arc (or a visible gap) so it is clear which line passes over which. On by default; set `elk.lineHops: false` to draw plain crossings, or `'gap'` to use gaps instead of arcs. + +```yaml +--- +config: + layout: elk + elk: + lineHops: gap +--- +``` + +The crossing detection and both styles already existed and were used by swimlanes — this registers the `afterPaint` hook that lets ELK use them. An edge that takes a hop loses its corner rounding on that segment, which is the trade for a readable crossing; curved edges are skipped rather than rewritten, to avoid corrupting their geometry. + +**Existing ELK diagrams with crossing edges will render differently.** diff --git a/.changeset/export-line-jumps.md b/.changeset/export-line-jumps.md new file mode 100644 index 00000000000..f116f721ef2 --- /dev/null +++ b/.changeset/export-line-jumps.md @@ -0,0 +1,9 @@ +--- +'mermaid': minor +--- + +feat: export `applyLineJumpsToSvg` so layout packages outside this one can draw line hops. + +Line jumps are applied after paint, once every edge has been emitted and the crossings are known. A layout that ships inside this package can reach into `rendering-util` to do that; one that ships separately, like `@mermaid-js/layout-elk`, cannot. Exporting it alongside the other common-renderer pieces lets an external layout register an `afterPaint` hook that draws hops the same way the built-in ones do. + +`EdgeGeom` and `LineJumpConfig` are exported with it, since they are the argument types. diff --git a/.changeset/line-hop-corner-clearance.md b/.changeset/line-hop-corner-clearance.md new file mode 100644 index 00000000000..0db70289520 --- /dev/null +++ b/.changeset/line-hop-corner-clearance.md @@ -0,0 +1,13 @@ +--- +'mermaid': patch +--- + +fix: don't draw a line hop that has no room next to a bend. + +A crossing close to a corner used to get a hop squeezed into whatever space was left — as little as 2.9px against a requested 6px, opening exactly on the corner's tangent point. At that size the arc no longer clears the line it is meant to hop, so the two strokes still touch and the corner's curve runs straight into the arc's. It reads as a rendering fault rather than as a crossing. + +Hops now keep a straight run clear of the bend, and one that would still have to shrink below 60% of the requested radius is dropped instead of drawn. An undrawn hop is an ordinary crossing, which is a much better failure than a broken-looking one. + +This shows up wherever a layout stacks edges in narrow lanes: ELK routes subgraph-internal edges 10px apart, and 10px does not hold a 7.07px corner cut plus a 6px hop. + +A crossing is also ignored now when it lands inside the stretch where either edge is rounding a bend. Crossings are found on polylines, but a rounded edge is not drawn as its polyline — it leaves the line up to 7.07px before each bend and rejoins it that far after. A crossing found inside that stretch is somewhere the stroke never goes, so the hop was arching over blank paper while the two lines carried on touching beside it. diff --git a/packages/mermaid-layout-elk/src/lineHops.ts b/packages/mermaid-layout-elk/src/lineHops.ts new file mode 100644 index 00000000000..881296d4b5a --- /dev/null +++ b/packages/mermaid-layout-elk/src/lineHops.ts @@ -0,0 +1,52 @@ +import { + applyLineJumpsToSvg, + type CommonLayoutPaintContext, + type EdgeGeom, + type LayoutData, +} from 'mermaid'; + +/** Radius of the arc drawn where one edge hops another. */ +const JUMP_RADIUS = 6; + +/** + * Draw a hop where two edges cross. + * + * Runs as `afterPaint`, because a hop is a property of the rendered path rather + * than of the layout: the crossings are only known once every edge has been + * emitted, and the fix is to rewrite the `d` of the edge that gives way. + * + * ELK's curve is compatible either way — `applyElkEdgeLayout` sets `rounded` + * for a routed edge and `linear` for its straight-line fallback, and + * `curveSupportsLineHops` accepts both. An edge that takes a hop loses its + * corner rounding in exchange, which is the trade the line-jump module + * documents. + */ +export function applyElkLineJumps( + data4Layout: LayoutData, + { measure }: CommonLayoutPaintContext +): void { + const lineHops = (data4Layout.config as { elk?: { lineHops?: boolean | string } })?.elk?.lineHops; + if (lineHops === false) { + return; + } + + const edgeGeometries: EdgeGeom[] = data4Layout.edges + .filter((edge) => Array.isArray(edge.points) && edge.points.length >= 2) + .map((edge) => ({ + id: edge.id, + points: edge.points!, + curve: edge.curve, + arrowTypeStart: edge.arrowTypeStart, + arrowTypeEnd: edge.arrowTypeEnd, + })) as EdgeGeom[]; + + applyLineJumpsToSvg( + (measure as { groups: { edgePaths: never } }).groups.edgePaths, + edgeGeometries, + { + enabled: true, + jumpRadius: JUMP_RADIUS, + jumpStyle: lineHops === 'gap' ? 'gap' : 'arc', + } + ); +} diff --git a/packages/mermaid/src/mermaid.ts b/packages/mermaid/src/mermaid.ts index 209a5e06412..20024e4aa64 100644 --- a/packages/mermaid/src/mermaid.ts +++ b/packages/mermaid/src/mermaid.ts @@ -54,6 +54,11 @@ export type { CommonLayoutRenderContext, CommonLayoutRendererDefinition, } from './rendering-util/layout-algorithms/common/index.js'; +// Exported for layout packages that live outside this one: an `afterPaint` +// hook is the only place line hops can be applied, and an external layout +// cannot reach into `rendering-util` the way the built-in ones do. +export { applyLineJumpsToSvg } from './rendering-util/rendering-elements/lineJump.js'; +export type { EdgeGeom, LineJumpConfig } from './rendering-util/rendering-elements/lineJump.js'; export interface RunOptions { /** diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts index 53cc9933f06..5c3ccddded9 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts @@ -686,4 +686,169 @@ describe('lineJump', () => { expect(e2.getAttribute('d')).toBe('M5,0 L5,10'); }); }); + + describe('hops with too little room next to a bend', () => { + const ROOMY: LineJumpConfig = { enabled: true, jumpRadius: 6, jumpStyle: 'arc' }; + + /** + * Geometry lifted from `elk-edge-cases/many-subgraphs-and-edges`, where + * `design-system -> mermaid-chart-app` leaves its node, turns north, turns + * east again at (430.6, 180.1), and is crossed 10px later at (440.6, 180.1) + * by `infrastructure -> auth-service`. + * + * 10px is `SUBGRAPH_EDGE_LANE_SPACING` — ELK stacks subgraph-internal edges + * in lanes that far apart — and it does not hold a 7.07px corner cut plus a + * 6px hop. The hop used to be fitted into what was left anyway, at 2.9px, + * starting exactly where the corner's quadratic ended. + */ + const CRAMPED: EdgeGeom[] = [ + { + id: 'designSystemToApp', + points: [ + { x: 395.6, y: 275.3 }, + { x: 430.6, y: 275.3 }, + { x: 430.6, y: 180.1 }, + { x: 502.1, y: 180.1 }, + ], + curve: 'rounded', + }, + { + id: 'infrastructureToAuth', + points: [ + { x: 440.6, y: 120 }, + { x: 440.6, y: 320 }, + ], + curve: 'rounded', + }, + ]; + + it('leaves the crossing alone rather than drawing an undersized arc', () => { + const d = processEdgesWithJumps(CRAMPED, ROOMY).get('designSystemToApp')!; + + // The crossing IS found — this is about what gets drawn for it, not about + // detection. + expect(findEdgeIntersections(CRAMPED)).toHaveLength(1); + + // No arc anywhere on the path, and no zero-length `L` parked on the + // corner's tangent point ahead of one. + expect(d).not.toMatch(/A/); + expect(d).not.toMatch(/L437\.696,180\.086 L437\.696,180\.086/); + }); + + it('still hops once the bend is far enough away', () => { + // Same edge, same crossing, but the turn moved back so the lane is 20px + // instead of 10px — now there is room for the full radius. + const roomy: EdgeGeom[] = [{ ...CRAMPED[0], points: [...CRAMPED[0].points] }, CRAMPED[1]]; + roomy[0].points[1] = { x: 420.6, y: 275.3 }; + roomy[0].points[2] = { x: 420.6, y: 180.1 }; + + const d = processEdgesWithJumps(roomy, ROOMY).get('designSystemToApp')!; + + expect(d).toContain('A6,6 0 0 1'); + }); + + /** + * `org -> platform` and `design -> app` from `knsv2.html`, verbatim. + * + * `org -> platform` runs east, turns south at (249, 1031.102) and carries on + * down. `design -> app` runs west along y=1033.625 and crosses that vertical + * — 2.5px below the turn, which is INSIDE the 7.07px the corner's quadratic + * takes to rejoin the line. + * + * So at the y where the hop was drawn, `org -> platform` is not on x=249 at + * all; it is still curving through its corner. The arc arched over blank + * paper while the two strokes carried on touching beside it. + */ + const ACROSS_A_CORNER: EdgeGeom[] = [ + { + id: 'orgToPlatform', + points: [ + { x: 169, y: 1031.102 }, + { x: 249, y: 1031.102 }, + { x: 249, y: 1345.091 }, + { x: 552.5, y: 1345.091 }, + ], + curve: 'rounded', + }, + { + id: 'designToApp', + points: [ + { x: 229, y: 1382.007 }, + { x: 229, y: 1033.625 }, + { x: 269, y: 1033.625 }, + { x: 351.5, y: 1033.625 }, + ], + curve: 'rounded', + }, + ]; + + it("ignores a crossing that lands inside the OTHER edge's corner", () => { + // Nothing is wrong with the hopping edge here: its own bend is 20px back, + // so the previous rule is happy to give it a full 6px arc. The problem is + // entirely on the edge being hopped. + expect(findEdgeIntersections(ACROSS_A_CORNER)).toEqual([]); + + const d = processEdgesWithJumps(ACROSS_A_CORNER, ROOMY).get('designToApp')!; + expect(d).not.toMatch(/A/); + }); + + it('hops normally once that corner is out of the way', () => { + // Same two edges, but `org -> platform` turns south 30px higher, so by + // y=1033.625 it has long since settled onto x=249 and there is a real + // vertical line to hop. + const clear: EdgeGeom[] = [ + { + ...ACROSS_A_CORNER[0], + points: [ + { x: 169, y: 1001.102 }, + { x: 249, y: 1001.102 }, + { x: 249, y: 1345.091 }, + { x: 552.5, y: 1345.091 }, + ], + }, + ACROSS_A_CORNER[1], + ]; + + expect(findEdgeIntersections(clear)).toHaveLength(1); + expect(processEdgesWithJumps(clear, ROOMY).get('designToApp')).toContain('A6,6'); + }); + + it('keeps a straight run between the corner and a hop it does draw', () => { + // A hop with just enough room still must not open ON the corner's tangent + // point — the quadratic and the arc would meet with nothing between them, + // which is the same squiggle as the undersized case, only bigger. + // + // The bend is at x=100, so its rounding ends at x=107.07. The crossing at + // x=113 leaves 5.93px, which the old clamp spent entirely on radius and + // opened the arc at exactly 107.07. + const edges: EdgeGeom[] = [ + { + id: 'bend', + points: [ + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 300, y: 100 }, + ], + curve: 'rounded', + }, + { + id: 'crosser', + points: [ + { x: 113, y: 0 }, + { x: 113, y: 200 }, + ], + }, + ]; + + const d = processEdgesWithJumps(edges, ROOMY).get('bend')!; + const arc = /L([\d.]+),100 A([\d.]+),/.exec(d); + expect(arc).not.toBeNull(); + + const [openAt, radius] = [arc![1], arc![2]].map(Number.parseFloat); + // Radius gives way, not the clearance: 2px of straight line survives + // between the end of the corner and the start of the arc. + expect(openAt - 107.071).toBeCloseTo(2, 2); + expect(radius).toBeCloseTo(3.929, 2); + }); + }); }); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts index fd4a34d9cfe..75038e454a1 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts @@ -21,6 +21,33 @@ const ROUNDED_CORNER_RADIUS = 5; * zero-length arcs on very crowded paths. */ const CORNER_EPSILON = 1e-5; +/** + * Straight run kept between a hop and the bend next to it. + * + * Without it a hop may start exactly at the tangent point of a rounded corner, + * so the path leaves the corner's quadratic and enters the arc with no straight + * run between them. The two curves read as one malformed squiggle rather than + * as a corner followed by a hop. + */ +const CORNER_JUMP_CLEARANCE = 2; + +/** + * Smallest share of the requested radius a hop may shrink to before it is + * dropped instead of drawn. + * + * A hop close to a bend has little room, and the clamps below will happily fit + * one into whatever is left. That is the wrong trade: an arc at half radius no + * longer clears the stroke it is meant to hop, so the lines still touch and the + * result looks like a rendering fault rather than a crossing. An undrawn hop is + * just an ordinary crossing, which is what every diagram looked like before + * hops existed — a much better failure than a broken-looking one. + * + * This happens for real: ELK routes subgraph-internal edges into lanes 10px + * apart, and a 10px offset cannot hold a 7.07px corner cut plus a 6px hop, so + * every crossing in such a lane was being drawn at 2.9px hard against the bend. + */ +const MIN_USEFUL_RADIUS_RATIO = 0.6; + export interface Point { x: number; y: number; @@ -133,6 +160,48 @@ function isHorizontalSeg(seg: Segment): boolean { return Math.abs(seg.b.x - seg.a.x) >= Math.abs(seg.b.y - seg.a.y); } +/** + * True if a crossing on `edge`'s segment `segIndex` at parameter `t` falls + * inside the stretch where the drawn stroke has left the polyline to round a + * bend. + * + * Crossings are found on polylines, but a `rounded` edge is not drawn as its + * polyline: `generateRoundedPath` replaces each bend with a quadratic that + * departs the line up to `cutLen` before the vertex and rejoins it `cutLen` + * after. Inside that stretch the polyline says the stroke is somewhere it is + * not, so a "crossing" computed there is at best mislocated and at worst + * fictional — and a hop drawn for it arches over blank paper while the two + * strokes still touch alongside it. + * + * Only `rounded` edges lie this way; every other supported curve is drawn as + * the polyline it describes. + */ +function crossingSitsInRoundedCorner(edge: EdgeGeom, segIndex: number, t: number): boolean { + if (edge.curve !== 'rounded') { + return false; + } + const pts = edge.points; + const a = pts[segIndex]; + const b = pts[segIndex + 1]; + if (!a || !b) { + return false; + } + const segLen = Math.hypot(b.x - a.x, b.y - a.y); + const d = t * segLen; + + const entering = + segIndex > 0 ? computeRoundedCorner(pts[segIndex - 1], a, b, ROUNDED_CORNER_RADIUS) : null; + if (entering && d < entering.cutLen) { + return true; + } + + const leaving = + segIndex + 2 < pts.length + ? computeRoundedCorner(a, b, pts[segIndex + 2], ROUNDED_CORNER_RADIUS) + : null; + return leaving !== null && segLen - d < leaving.cutLen; +} + export function findEdgeIntersections(edges: EdgeGeom[]): Crossing[] { const crossings: Crossing[] = []; @@ -150,6 +219,15 @@ export function findEdgeIntersections(edges: EdgeGeom[]): Crossing[] { continue; } + // Either edge rounding a bend here means the polyline is not where + // the stroke is, so there is nothing trustworthy to hop over. + if ( + crossingSitsInRoundedCorner(edgeA, si, hit.tA) || + crossingSitsInRoundedCorner(edgeB, sj, hit.tB) + ) { + continue; + } + // Orthogonal-orientation rule: when one segment is horizontal- // dominant and the other vertical-dominant, the HORIZONTAL one // gets the jump (classic line-hop convention — arcs arch upward @@ -410,12 +488,18 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf } } - // Jumps clamped so they don't overlap corners at either end of the - // segment or each other. - const segJumps = [...(bySeg.get(i) ?? [])].sort((a, b) => a.t - b.t); - for (const j of segJumps) { - j.r = Math.min(j.r, j.d - segStartConsumed, segEndStop - j.d); - } + // Clamp each jump to the room between the bends at either end of the + // segment, then drop the ones with too little room to be worth drawing. + // Dropping happens BEFORE the adjacency pass below so that a hop being + // squeezed out by a corner does not also shrink its neighbours. + const minUsefulRadius = config.jumpRadius * MIN_USEFUL_RADIUS_RATIO; + const segJumps = [...(bySeg.get(i) ?? [])] + .sort((a, b) => a.t - b.t) + .filter((j) => { + const room = Math.min(j.d - segStartConsumed, segEndStop - j.d) - CORNER_JUMP_CLEARANCE; + j.r = Math.min(j.r, room); + return j.r >= minUsefulRadius; + }); for (let k = 0; k < segJumps.length - 1; k++) { const gap = segJumps[k + 1].d - segJumps[k].d; if (segJumps[k].r + segJumps[k + 1].r > gap) { From 1befa91746916a691022b5573364cd35c57407b8 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 16:58:22 +0200 Subject: [PATCH 08/31] fix(elk): attach non-rect endpoints on the outline, and straighten terminal jogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two edge-endpoint fixes, both in `geometry.ts`. ELK models every node as a rectangle, so for a diamond, stadium or hexagon the port it chooses is on the bounding box rather than on the shape. The adapter used to resolve that by attaching along the ray from the node CENTRE, which lands on the outline at a different offset than the port and so opened every such edge with a diagonal segment. `outlineAttachPoint` now bisects along the edge's own departure axis using only `node.intersect`, so the edge leaves perpendicular and meets the shape where it was heading. `straightenTerminalJogs` removes the tiny step ELK leaves between a port and the channel an edge runs in — two rounded corners stacked on each other, often under a pixel apart, which reads as a kink under the arrowhead. It is removed by moving the channel onto the port's row, NOT the port onto the channel: sliding an attachment along a node's border leaves it somewhere the layout did not choose, and a node whose other edges are still evenly spread then looks lopsided. That constraint makes the pass conservative, and deliberately so. The WHOLE run has to move or the moved and unmoved halves meet at a diagonal, so an edge whose run ends at the far terminal is skipped rather than drag the other end's port. Straightening also runs as a post-pass over the finished layout and counts each candidate against every other edge, dropping any that would add a crossing. --- .changeset/elk-non-rect-attachment.md | 13 ++ .changeset/elk-straighten-terminal-jogs.md | 11 + .../src/__tests__/geometry.spec.ts | 197 ++++++++++++++++++ packages/mermaid-layout-elk/src/geometry.ts | 88 ++++++++ 4 files changed, 309 insertions(+) create mode 100644 .changeset/elk-non-rect-attachment.md create mode 100644 .changeset/elk-straighten-terminal-jogs.md diff --git a/.changeset/elk-non-rect-attachment.md b/.changeset/elk-non-rect-attachment.md new file mode 100644 index 00000000000..b5bd8819e9d --- /dev/null +++ b/.changeset/elk-non-rect-attachment.md @@ -0,0 +1,13 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: edges leave diamonds, stadiums and other non-rectangular shapes without kinking. + +ELK routes to ports on a node's bounding box and always leaves one perpendicular to the side it sits on. For a rectangle that port is the attachment point; for anything else the outline is inside the box, so the attachment has to move inwards — and the direction it moves in decides whether the edge stays orthogonal. + +It used to move along the ray from the node's centre, which lands on the outline at a different offset along the side than the port ELK chose, so the opening segment came out diagonal. The attachment now walks the outline along the edge's own departure axis, staying collinear with ELK's stub: the edge leaves the outline, crosses the box, and carries on in one straight line. Rectangular nodes are unaffected. + +Also in this release: + +- The default `elk.nodePlacementStrategy` is now `NETWORK_SIMPLEX` rather than `BRANDES_KOEPF`. **This changes the layout of existing ELK diagrams**, though most are unaffected: over the ELK edge-case corpus, 9 of 13 diagrams are byte-identical and the rest improve. Set `elk: { nodePlacementStrategy: 'BRANDES_KOEPF' }` to keep the previous placement. diff --git a/.changeset/elk-straighten-terminal-jogs.md b/.changeset/elk-straighten-terminal-jogs.md new file mode 100644 index 00000000000..bf430973618 --- /dev/null +++ b/.changeset/elk-straighten-terminal-jogs.md @@ -0,0 +1,11 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: an edge no longer leaves a node with a tiny kink. + +ELK spreads an edge's port evenly along a node's side, then routes the edge down a channel whose row rarely lines up with that port exactly. The leftover is a staircase of a few pixels right at the border: leave the port, run a short distance, step perpendicular onto the channel, carry on. With rounded corners the two micro-bends sit on top of each other and read as a glitch — one edge in the sample corpus stepped 3.25px and rendered as two quadratic curves with a zero-length segment between them. + +The step is now removed by moving the channel onto the port's row, so the edge draws as one straight line and **both ports stay exactly where the layout put them** — sliding an attachment along a node's border leaves a node whose other edges are still evenly spread looking lopsided. Only a step next to a node is touched, and only when it is small and the edge continues the same way afterwards, so a real turn is never collapsed. An edge is left alone entirely when moving its run would drag the far port, or would buy a crossing. + +Set `elk.straightenEdges: false` to keep the previous behaviour. diff --git a/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts b/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts index 101dc5f10ce..d8776397b1f 100644 --- a/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts @@ -5,9 +5,13 @@ import { makeInsidePoint, tryNodeIntersect, replaceEndpoint, + outlineAttachPoint, type RectLike, type P, } from '../geometry.js'; +// Lives in render.ts rather than geometry.ts, but it is pure point maths and +// belongs with the other geometry cases. +import { straightenTerminalJogs } from '../render.js'; const approx = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) < eps; @@ -64,4 +68,197 @@ describe('geometry helpers', () => { replaceEndpoint(pts2, 'start', { x: 0, y: 0 }); expect(pts2.length).toBe(1); }); + + describe('outlineAttachPoint', () => { + // A diamond 105.48 wide and tall, centred like the `diamond-intersections` + // fixture: outline through the midpoints of its bounding box sides. + const half = 52.74; + const bounds: RectLike = { x: 76.74, y: 428.5, width: half * 2, height: half * 2 }; + const diamond = { + intersect: (p: P): P => { + // Crossing of the ray centre -> p with |dx|/half + |dy|/half = 1. + const dx = p.x - bounds.x; + const dy = p.y - bounds.y; + const t = half / (Math.abs(dx) + Math.abs(dy)); + return { x: bounds.x + dx * t, y: bounds.y + dy * t }; + }, + }; + + it('attaches on the outline at the port’s own offset along the side', () => { + // ELK's port sits on the bounding box at y = 454.87 and departs east. The + // attachment must keep that y, so the opening segment stays horizontal; + // the centre ray would instead land at y = 446.08 and open diagonally. + const port: P = { x: 129.48, y: 454.87 }; + const next: P = { x: 144.48, y: 454.87 }; + + const attach = outlineAttachPoint(diamond, bounds, port, next)!; + + expect(attach.y).toBe(port.y); + expect(approx(attach.x, 103.11, 0.01)).toBe(true); + // On the outline: |dx| + |dy| === half. + expect( + approx(Math.abs(attach.x - bounds.x) + Math.abs(attach.y - bounds.y), half, 0.01) + ).toBe(true); + }); + + it('keeps a vertical departure vertical', () => { + const port: P = { x: 60, y: 481.24 }; + const next: P = { x: 60, y: 520 }; + + const attach = outlineAttachPoint(diamond, bounds, port, next)!; + + expect(attach.x).toBe(port.x); + expect( + approx(Math.abs(attach.x - bounds.x) + Math.abs(attach.y - bounds.y), half, 0.01) + ).toBe(true); + }); + + it('returns the port unchanged for a shape whose outline is its box', () => { + const rect: RectLike = { x: 100, y: 100, width: 80, height: 40 }; + const rectNode = { + intersect: (p: P): P => { + const dx = p.x - rect.x; + const dy = p.y - rect.y; + const t = Math.min(40 / Math.abs(dx || 1e-9), 20 / Math.abs(dy || 1e-9)); + return { x: rect.x + dx * t, y: rect.y + dy * t }; + }, + }; + const port: P = { x: 140, y: 112 }; + + const attach = outlineAttachPoint(rectNode, rect, port, { x: 180, y: 112 })!; + + expect(approx(attach.x, port.x, 0.01)).toBe(true); + expect(approx(attach.y, port.y, 0.01)).toBe(true); + }); + + it('declines a shapeless node rather than guessing', () => { + expect( + outlineAttachPoint({}, bounds, { x: 129.48, y: 454.87 }, { x: 144.48, y: 454.87 }) + ).toBe(null); + }); + }); + + describe('straightenTerminalJogs', () => { + // Every case keeps BOTH ports exactly where they are: the step is removed by + // moving the channel onto the port's row, never the other way round. Moving + // a port slides the attachment along the node border and leaves a node whose + // other edges are still evenly spread looking lopsided. + + it('moves the channel onto the port row, leaving the port alone', () => { + // Leaves the port at y=116.25, steps 3.25 down onto the channel, runs on, + // then turns. The run moves up to the port instead. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 218, y: 116.25 }, + { x: 218, y: 119.5 }, + { x: 400, y: 119.5 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual([ + { x: 193, y: 116.25 }, + { x: 400, y: 116.25 }, + { x: 400, y: 300 }, + ]); + }); + + it('moves a whole multi-segment run, not just its first leg', () => { + // The channel carries on past the first bend. Shifting only part of it + // would leave a diagonal where the moved and unmoved halves meet. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 218, y: 116.25 }, + { x: 218, y: 119.5 }, + { x: 300, y: 119.5 }, + { x: 400, y: 119.5 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual([ + { x: 193, y: 116.25 }, + { x: 300, y: 116.25 }, + { x: 400, y: 116.25 }, + { x: 400, y: 300 }, + ]); + }); + + it('handles a sub-pixel step', () => { + // ELK routinely leaves under a pixel between port row and channel row. It + // still paints as two rounded corners stacked on each other. + const pts: P[] = [ + { x: 193, y: 116.5 }, + { x: 218, y: 116.5 }, + { x: 218, y: 117.358 }, + { x: 400, y: 117.358 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual([ + { x: 193, y: 116.5 }, + { x: 400, y: 116.5 }, + { x: 400, y: 300 }, + ]); + }); + + it('refuses when the run ends at the far port', () => { + // Moving this run would drag the other end's port — the very thing the + // rewrite avoids — so the edge is left exactly as routed. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 218, y: 116.25 }, + { x: 218, y: 119.5 }, + { x: 300, y: 119.5 }, + { x: 400, y: 119.5 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a step too large to be a port connector', () => { + const pts: P[] = [ + { x: 193, y: 100 }, + { x: 218, y: 100 }, + { x: 218, y: 140 }, + { x: 400, y: 140 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a step that turns too far from the node', () => { + // Small step, but the corner is 120 out: a routing decision, not the + // port-to-channel connector. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 313, y: 116.25 }, + { x: 313, y: 119.5 }, + { x: 415, y: 119.5 }, + { x: 415, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a step that reverses direction', () => { + const pts: P[] = [ + { x: 193, y: 116 }, + { x: 218, y: 116 }, + { x: 218, y: 119 }, + { x: 100, y: 119 }, + { x: 100, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a route with nothing to collapse', () => { + const pts: P[] = [ + { x: 193, y: 120 }, + { x: 315, y: 120 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + }); }); diff --git a/packages/mermaid-layout-elk/src/geometry.ts b/packages/mermaid-layout-elk/src/geometry.ts index 5cda865debe..be0fe4742d9 100644 --- a/packages/mermaid-layout-elk/src/geometry.ts +++ b/packages/mermaid-layout-elk/src/geometry.ts @@ -162,6 +162,94 @@ export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P return intersection(bounds, outside, inside); }; +/** Bisection steps used to walk a ray onto the node outline. */ +const OUTLINE_RAY_STEPS = 40; + +/** + * Whether a point lies inside the node's outline. + * + * Derived from `intersect` alone, so it needs no per-shape knowledge: the + * shape's `intersect` returns where the ray from the node CENTRE through the + * probe leaves the outline, so the probe is inside exactly when it is no + * further from the centre than that crossing is. Valid for any outline that is + * star-shaped about its centre, which every built-in shape is. + */ +const insideOutline = (node: NodeLike, centre: P, probe: P): boolean => { + const crossing = node.intersect?.(probe); + if (!crossing) { + return false; + } + const probeDist = Math.hypot(probe.x - centre.x, probe.y - centre.y); + const outlineDist = Math.hypot(crossing.x - centre.x, crossing.y - centre.y); + return probeDist <= outlineDist + 1e-9; +}; + +/** + * Where the node's outline meets the ray that runs into the node from `port`, + * against the direction the edge departs in. + * + * ELK routes to ports on the node's BOUNDING BOX, and always leaves one + * perpendicular to the side it sits on. For a rectangle that port is already + * the attachment point. For anything else the outline is inside the box, so the + * attachment has to move inwards — and the direction it moves in decides + * whether the edge stays orthogonal. + * + * Moving along the centre ray (what `intersect` does on its own) lands on the + * outline at a DIFFERENT offset along the side than the port, so the opening + * segment comes out diagonal and the edge visibly kinks as it leaves the shape. + * Moving along the departure axis instead keeps the attachment collinear with + * ELK's own stub: the edge leaves the outline, crosses the box, and carries on + * in one straight line. + * + * Returns null when the ray cannot be resolved — no `intersect`, a departure + * direction that is not axis-aligned, or an interior sample that is not + * actually inside — leaving the caller on its existing path. + */ +export const outlineAttachPoint = ( + node: NodeLike, + bounds: RectLike, + port: P, + next: P +): P | null => { + if (!node?.intersect) { + return null; + } + + const dx = next.x - port.x; + const dy = next.y - port.y; + if (dx === 0 && dy === 0) { + return null; + } + + const centre = { x: bounds.x, y: bounds.y }; + // The departure axis. A diagonal departure has no single axis to preserve, so + // there is nothing here to improve on. + const horizontal = Math.abs(dx) > Math.abs(dy); + const along = (t: number): P => (horizontal ? { x: t, y: port.y } : { x: port.x, y: t }); + + // Walk in from the centre-line towards the port: inside at one end, on or + // outside the outline at the other. + let inner = horizontal ? centre.x : centre.y; + let outer = horizontal ? port.x : port.y; + if (!insideOutline(node, centre, along(inner))) { + return null; + } + if (insideOutline(node, centre, along(outer))) { + // The port itself is on or inside the outline — it IS the attachment. + return { ...port }; + } + + for (let step = 0; step < OUTLINE_RAY_STEPS; step++) { + const mid = (inner + outer) / 2; + if (insideOutline(node, centre, along(mid))) { + inner = mid; + } else { + outer = mid; + } + } + return along(inner); +}; + export const computeNodeIntersection = ( node: NodeLike, bounds: RectLike, From 3b09225e025984061dde26a7946787346cddb635 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 16:58:22 +0200 Subject: [PATCH 09/31] fix(elk): buy subgraph edge spacing separately from the base value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subgraph could carry 74px of padding on one side and 24px everywhere else, which reads as a mistake because nothing visible occupies it. The space is a routing lane, held open for an edge that runs against the flow of the layout and has to be routed back around the outside — so only groups containing such an edge were affected, which is why it looked arbitrary. The lane's width came from `spacing.baseValue`, which was doing two jobs. Every unset ELK spacing derives from it, so it had to stay large enough that an edge got a straight run before the node it enters — below about 40 the approach came out shorter than the 10px arrowhead and the turn read as happening underneath it. But an edge routed down the inside of a frame claims a lane the same width, so paying for the approach out of the base value also pushed groups clear of their own borders. Split them: base value down to 24, with the approach run, node separation and edge separation set explicitly. Two things worth recording, because both cost time: - The key that buys the approach is `elk.layered.spacing.edgeNodeBetweenLayers`. A previous attempt used `edgeEdgeBetweenLayers`, which is edge-to-edge and a different quantity, and the note left behind concluded that ELK ignored edge-node spacing "in every key form". It does not. - Node separation derives from the base value too, so lowering that alone pulled sibling nodes together until they were touching. `spacing.nodeNode` is now set on its own, spelled the same way as the rectpacking override so a container cannot hold two values for one option, and `clearContainerAlgorithmOptions` restores it alongside the base value. Subgraph nodes are also placed with NETWORK_SIMPLEX and PORT_POSITION flexibility, which keeps a group's nodes aligned with one another rather than drifting, and lets a node shift so an edge leaves straight instead of bending off the port. Measured on a 25-node six-subgraph diagram: the two lopsided groups are back to 24/24, and the shortest approach run over all 28 edges is 30px with none under 15, against 7 under 15 when the base value alone was lowered. Resolves #8150 --- .changeset/elk-subgraph-spacing-split.md | 17 + .../src/__tests__/render.spec.ts | 33 +- packages/mermaid-layout-elk/src/render.ts | 463 ++++++++++++++++-- 3 files changed, 468 insertions(+), 45 deletions(-) create mode 100644 .changeset/elk-subgraph-spacing-split.md diff --git a/.changeset/elk-subgraph-spacing-split.md b/.changeset/elk-subgraph-spacing-split.md new file mode 100644 index 00000000000..2b088adf38a --- /dev/null +++ b/.changeset/elk-subgraph-spacing-split.md @@ -0,0 +1,17 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: stop ELK subgraphs padding one side more than the other. + +A subgraph could end up with far more space on one side than the other for no reason a reader could see — 74px on the right of one group against 24px everywhere else. The extra space was a routing lane held open for an edge that runs against the flow of the layout and has to be routed back around, and its width came from `spacing.baseValue`. + +That base value was doing two jobs at once. Every unset ELK spacing derives from it, so it had to stay large enough that an edge got a straight run before the node it enters — below about 40 the approach came out shorter than the arrowhead and the turn read as happening underneath it. But an edge routed down the inside of a frame claims a lane the same width, so paying for the approach out of the base value also pushed groups clear of their own borders. + +The two are now bought separately. The base value drops to 24, and the approach run, node separation and edge separation are set explicitly. Subgraph padding is even again, and edges keep the run they had. + +`elk.layered.spacing.edgeNodeBetweenLayers` is the key that buys the approach. An earlier attempt used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is edge-to-edge and a different quantity, and a note in the source concluded from it that ELK ignored edge-node spacing "in every key form". It does not; that note was wrong and is corrected. + +Subgraph nodes are also placed with `NETWORK_SIMPLEX` and `PORT_POSITION` flexibility, which keeps a group's nodes aligned with one another instead of drifting, and lets a node shift so an edge can leave straight rather than bending off the port. + +**Existing diagrams with subgraphs will render differently** — groups get tighter and more even. diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index 8b93dbe9785..76a57bb2547 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -129,10 +129,32 @@ describe('buildSubgraphLayoutOptions', () => { it('handles undefined elkConfig gracefully', () => { const opts = buildSubgraphLayoutOptions({}, undefined, 'layered'); expect(opts['elk.layered.mergeEdges']).toBeUndefined(); - expect(opts['nodePlacement.strategy']).toBeUndefined(); + // With no config at all the `default` preset supplies the placement + // strategy, so this is no longer undefined. + expect(opts['nodePlacement.strategy']).toBe('LINEAR_SEGMENTS'); expect(opts['elk.layered.nodePlacement.bk.fixedAlignment']).toBe('NONE'); }); + it('lets an explicit strategy beat the preset', () => { + // A preset is a starting point, not a lock: naming one option explicitly + // overrides that option and leaves the rest of the preset in place. + const opts = buildSubgraphLayoutOptions( + {}, + { preset: 'legacy', nodePlacementStrategy: 'SIMPLE' }, + 'layered' + ); + expect(opts['nodePlacement.strategy']).toBe('SIMPLE'); + }); + + it('takes the placement strategy from the named preset', () => { + expect( + buildSubgraphLayoutOptions({}, { preset: 'legacy' }, 'layered')['nodePlacement.strategy'] + ).toBe('BRANDES_KOEPF'); + expect( + buildSubgraphLayoutOptions({}, { preset: 'depthFirst' }, 'layered')['nodePlacement.strategy'] + ).toBe('LINEAR_SEGMENTS'); + }); + it('applies a per-group algorithm from metadata with SEPARATE_CHILDREN', () => { const opts = buildSubgraphLayoutOptions( { labelData: { width: 30, height: 14 }, metadata: { algorithm: 'elk.box' } }, @@ -735,8 +757,13 @@ describe('clearContainerAlgorithmOptions', () => { clearContainerAlgorithmOptions(options); - expect(options['spacing.baseValue']).toBe(30); - expect(options).not.toHaveProperty('spacing.nodeNode'); + // Back to DEFAULT_SUBGRAPH_SPACING_BASE_VALUE. It no longer buys the + // approach run or the node gap — those are set on their own now — so it is + // free to be small, and the container's padding follows it. + expect(options['spacing.baseValue']).toBe(24); + // Restored too, rather than left deleted: rectpacking overrode it, and + // dropping it would hand the container ELK's node spacing instead of ours. + expect(options['spacing.nodeNode']).toBe(50); expect(options).not.toHaveProperty('elk.rectpacking.trybox'); }); diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 1f35abd7b7a..67398263ed0 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -8,12 +8,14 @@ import mermaid, { import { curveLinear } from 'd3'; import ELK from 'elkjs/lib/elk.bundled.js'; import { type TreeData, findCommonAncestor } from './find-common-ancestor.js'; +import { applyElkLineJumps } from './lineHops.js'; import { type P, type RectLike, outsideNode, computeNodeIntersection, + outlineAttachPoint, replaceEndpoint, onBorder, } from './geometry.js'; @@ -59,6 +61,10 @@ interface NodeWithVertex { interface ElkSubgraphConfig { mergeEdges?: boolean; + straightenEdges?: boolean; + preset?: string; + layeringStrategy?: string; + layeringLayerBound?: number; nodePlacementAlignment?: string; nodePlacementStrategy?: string; } @@ -69,6 +75,23 @@ interface ElkPreparedLayout { interface ElkLayoutContext { algorithm?: string; + /** + * Extra root-graph `layoutOptions`, merged last over + * {@link createRootElkGraph}'s defaults. + * + * NOT user-facing config: nothing in `config.schema.yaml` writes it and + * production `render()` never sets it. It exists so the DDLT configuration + * sweep can try ELK options that are currently hardcoded here — spacings, + * edge routing, node placement — WITHOUT forking the layout pipeline. A + * sweep that reimplemented `createRootElkGraph` would be measuring a graph + * the browser never builds, which is the exact failure the single-pipeline + * rule exists to prevent. + * + * Promote a winning option to a real default in `createRootElkGraph`, or to + * a `config.elk.*` key if it should be author-controlled. Do not reach for + * this from product code. + */ + rootLayoutOptions?: Record; common: { lineBreakRegex: RegExp }; getConfig: () => any; interpolateToCurve: (interpolate: string | undefined, defaultCurve: unknown) => unknown; @@ -108,8 +131,44 @@ const ARROW_MAP: Record = { double_arrow_circle: ['arrow_circle', 'arrow_circle'], }; const DEFAULT_NODE_PLACEMENT_ALIGNMENT = 'NONE'; -/** Default `spacing.baseValue` for a subgraph that has no algorithm of its own. */ -const DEFAULT_SUBGRAPH_SPACING_BASE_VALUE = 30; +/** Padding between a subgraph frame and its children. ELK's own default is 12. */ +const SUBGRAPH_PADDING = 24; +/** + * Default `spacing.baseValue` for a subgraph that has no algorithm of its own. + * + * Every unset spacing derives from this, which is why it used to be 50: the + * gap ELK derives for an edge approaching a node comes out at roughly half, + * and below about 40 the approach ran shorter than the 10px arrowhead, so the + * turn read as happening underneath it. + * + * Paying for that approach out of the base value overcharged everything else. + * An edge routed down the inside of a frame claims a lane the same width, so a + * group with a couple of them was pushed 50px clear of its own border on that + * side and nowhere else — visible as a subgraph padded on one side only, for + * no reason a reader can see. + * + * The two are now set separately: this stays tight, and + * `elk.layered.spacing.edgeNodeBetweenLayers` buys the approach on its own. + * An earlier note here claimed ELK ignored an explicit edge-node spacing "in + * every key form"; it does honour the layered-scoped key, and the attempt that + * failed had used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is + * edge-to-edge and a different quantity. + */ +const DEFAULT_SUBGRAPH_SPACING_BASE_VALUE = 24; +/** + * Gap between two sibling nodes in a subgraph. + * + * Also used to derive from `spacing.baseValue`, so lowering that pulled a + * group's nodes together until they tripped the validator's + * `node-node-padding` rule — three fixtures went invalid on it. 50 is what the + * old base value yielded, restored here so the base value is free to be small. + * + * Deliberately spelled the same way as the `elk.rectpacking` override in + * `RECTPACKING_OPTIONS`: ELK reads `spacing.nodeNode` and `elk.spacing.nodeNode` + * as the same option, so using both forms would leave a rectpacking container + * carrying two values for it and no say in which one won. + */ +const DEFAULT_SUBGRAPH_NODE_SPACING = 50; /** Inner padding reserved around a container that runs its own algorithm. */ const CONTAINER_PADDING = 15; /** Same, for `elk.rectpacking`, which packs tighter. */ @@ -160,9 +219,12 @@ export function clearContainerAlgorithmOptions(layoutOptions: Record = { 'spacing.baseValue': DEFAULT_SUBGRAPH_SPACING_BASE_VALUE, + // The straight run an edge gets before the node it enters, bought on its + // own rather than out of `spacing.baseValue` — see the note there. This is + // the layered-scoped key; the unscoped `spacing.edgeNodeBetweenLayers` is + // not an ELK id at all and setting it does nothing. + 'elk.layered.spacing.edgeNodeBetweenLayers': 40, + // Separation between edges sharing a lane. Also raised off the base value, + // so that lowering the base does not leave parallel edges touching. + 'elk.spacing.edgeEdge': 20, + // Node separation, likewise bought on its own — see the note on the constant. + 'spacing.nodeNode': DEFAULT_SUBGRAPH_NODE_SPACING, + // Breathing room between a frame and its children. Set explicitly rather + // than left to ELK's default of 12. The top gets the same value as the + // rest: ELK reserves the subgraph's own title strip on top of whatever is + // given here, so adding the label height again double-counts it. + 'elk.padding': `[top=${SUBGRAPH_PADDING},left=${SUBGRAPH_PADDING},bottom=${SUBGRAPH_PADDING},right=${SUBGRAPH_PADDING}]`, 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', - 'nodePlacement.strategy': elkConfig?.nodePlacementStrategy, + 'nodePlacement.strategy': + elkConfig?.nodePlacementStrategy ?? resolveElkPreset(elkConfig?.preset).placement, 'elk.layered.mergeEdges': elkConfig?.mergeEdges, 'elk.layered.nodePlacement.bk.fixedAlignment': elkConfig?.nodePlacementAlignment ?? DEFAULT_NODE_PLACEMENT_ALIGNMENT, + // Containers place their own children. NETWORK_SIMPLEX balances a node + // against all of its neighbours, which keeps a group's nodes aligned with + // each other instead of drifting; PORT_POSITION lets it shift a node so an + // edge can leave straight rather than bending immediately off the port. + 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'PORT_POSITION', }; // Apply per-group algorithm from metadata (e.g. @{algorithm: elk.box}). @@ -496,7 +580,11 @@ export function buildElkGraphFromLayoutData( elkContext: ElkLayoutContext ): ElkLayoutState { const nodeDb: Record = {}; - const elkGraph = createRootElkGraph(data4Layout, elkContext.algorithm); + const elkGraph = createRootElkGraph( + data4Layout, + elkContext.algorithm, + elkContext.rootLayoutOptions + ); const dir = (data4Layout as { direction?: string }).direction ?? 'DOWN'; elkGraph.layoutOptions['elk.direction'] = dir2ElkDirection(dir); @@ -512,6 +600,7 @@ export function buildElkGraphFromLayoutData( } export const render = createCommonLayoutRenderer({ + afterPaint: applyElkLineJumps, prepareLayout: prepareLayoutForElk, // ELK derives a compound node's minimum size from the measured cluster label, // so the label has to be measured the way `insertCluster` paints it — @@ -579,6 +668,9 @@ function getElkLayoutContext( algorithm: context.preparedLayout?.algorithm ?? (context.options as { algorithm?: string } | undefined)?.algorithm, + rootLayoutOptions: ( + context.options as { rootLayoutOptions?: Record } | undefined + )?.rootLayoutOptions, common: helpers.common, getConfig: helpers.getConfig, interpolateToCurve: helpers.interpolateToCurve as ( @@ -589,27 +681,95 @@ function getElkLayoutContext( }; } -function createRootElkGraph(data4Layout: LayoutData, algorithm: string | undefined): any { +/** + * Scratch overrides for local experimentation. MUST be empty on `develop`. + * + * Spread last into the root graph's `layoutOptions`, so anything here wins over + * the defaults above — including the keys wired to `config.elk.*`. That is the + * point: edit one line, let the dev server rebuild, and compare renders without + * touching a diagram's frontmatter or the config schema. + * + * It is also why this must not ship. An entry here silently disables the + * matching user-facing option for every diagram, and the symptom — "this config + * key does nothing" — gives no hint where to look. `elk.cycleBreakingStrategy` + * was dead this way, and it took a bisect against the raw ELK option to notice. + */ +/** + * Named combinations of the three options that decide where nodes end up. + * + * Layering picks the column, node placement the coordinate within it, and cycle + * breaking which edges are reversed and therefore which ones detour. They run in + * different phases and do not interact, so a preset is a named triple rather + * than a mode of its own. + * + * An explicit `elk.layeringStrategy` / `nodePlacementStrategy` / + * `cycleBreakingStrategy` beats the preset for that one option — which is why + * `defaultConfig` leaves all three undefined rather than giving them values. + */ +const ELK_PRESETS: Record = + { + /** Keeps chains of nodes aligned. */ + default: { + layering: 'NETWORK_SIMPLEX', + placement: 'LINEAR_SEGMENTS', + cycleBreaking: 'GREEDY_MODEL_ORDER', + }, + /** + * What shipped before presets: straighter long edges, less alignment. + * + * `GREEDY`, not `GREEDY_MODEL_ORDER`, is deliberate. The schema advertised + * the latter, but `defaultConfig` never listed `cycleBreakingStrategy`, so it + * reached ELK as undefined and ELK's own default applied. This preset + * reproduces what `develop` actually renders, not what its schema claimed. + * Layering is ELK's default too — `develop` does not wire the option at all. + */ + legacy: { + layering: 'NETWORK_SIMPLEX', + placement: 'BRANDES_KOEPF', + cycleBreaking: 'GREEDY', + }, + /** As `default`, but shorter back edges on graphs that have many. */ + depthFirst: { + layering: 'NETWORK_SIMPLEX', + placement: 'LINEAR_SEGMENTS', + cycleBreaking: 'DEPTH_FIRST', + }, + }; + +/** Resolve a preset name, falling back to `default` for an unknown one. */ +export function resolveElkPreset(name: string | undefined) { + return ELK_PRESETS[name ?? 'default'] ?? ELK_PRESETS.default; +} + +function createRootElkGraph( + data4Layout: LayoutData, + algorithm: string | undefined, + rootLayoutOptions?: Record +): any { + const preset = resolveElkPreset(data4Layout.config.elk?.preset); const graph = { id: 'root', layoutOptions: { 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.algorithm': algorithm, - 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy, + 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy ?? preset.placement, 'elk.layered.nodePlacement.bk.fixedAlignment': data4Layout.config.elk?.nodePlacementAlignment ?? DEFAULT_NODE_PLACEMENT_ALIGNMENT, 'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges, 'elk.direction': 'DOWN', 'spacing.baseValue': 40, + 'elk.layered.crossingMinimization.forceNodeModelOrder': data4Layout.config.elk?.forceNodeModelOrder, 'elk.layered.considerModelOrder.strategy': data4Layout.config.elk?.considerModelOrder, 'elk.layered.unnecessaryBendpoints': true, - 'elk.layered.cycleBreaking.strategy': data4Layout.config.elk?.cycleBreakingStrategy, + 'elk.layered.cycleBreaking.strategy': + data4Layout.config.elk?.cycleBreakingStrategy ?? preset.cycleBreaking, + 'elk.layered.layering.strategy': data4Layout.config.elk?.layeringStrategy ?? preset.layering, + // Only COFFMAN_GRAHAM reads this; the others ignore it. + 'elk.layered.layering.coffmanGraham.layerBound': data4Layout.config.elk?.layeringLayerBound, - // 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER', - // 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER', - // 'spacing.nodeNode': 20, + // 'spacing.nodeNode': 120, // 'spacing.nodeNodeBetweenLayers': 25, // 'spacing.edgeNode': 20, // 'spacing.edgeNodeBetweenLayers': 10, @@ -618,27 +778,10 @@ function createRootElkGraph(data4Layout: LayoutData, algorithm: string | undefin // 'spacing.nodeSelfLoop': 20, // Tweaking options - // 'nodePlacement.favorStraightEdges': true, - // 'elk.layered.nodePlacement.favorStraightEdges': true, - // 'nodePlacement.feedbackEdges': true, 'elk.layered.wrapping.multiEdge.improveCuts': true, 'elk.layered.wrapping.multiEdge.improveWrappedEdges': true, - // 'elk.layered.wrapping.strategy': 'MULTI_EDGE', - // 'elk.layered.wrapping.strategy': 'SINGLE_EDGE', 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY', 'elk.layered.mergeHierarchyEdges': true, - - // 'elk.layered.feedbackEdges': true, - // 'elk.layered.crossingMinimization.semiInteractive': true, - // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1, - // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0, - // 'elk.layered.wrapping.validify.strategy': 'LOOK_BACK', - // 'elk.insideSelfLoops.activate': true, - // 'elk.separateConnectedComponents': true, - // 'elk.alg.layered.options.EdgeStraighteningStrategy': 'NONE', - // 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', - // 'elk.layered.considerModelOrder.strategy': 'EDGES', - // 'elk.layered.wrapping.cutting.strategy': 'ARD', }, children: [], edges: [], @@ -652,6 +795,12 @@ function createRootElkGraph(data4Layout: LayoutData, algorithm: string | undefin }); } + // Last, so a sweep override beats every default above. See + // `ElkLayoutContext.rootLayoutOptions` for why this exists. + if (rootLayoutOptions) { + Object.assign(graph.layoutOptions, rootLayoutOptions); + } + return graph; } @@ -1009,6 +1158,205 @@ function applyElkNodePositions( }); } +/** + * Largest port-to-channel jog worth collapsing. + * + * ELK layered spreads an edge's port evenly along the node's side, then routes + * the edge down an inter-layer channel whose row rarely lines up with that port + * exactly. The leftover is a staircase right at the border: leave the port, run + * a few pixels, step perpendicular onto the channel, carry on. With rounded + * corners the two micro-bends sit on top of each other and read as a glitch. + * + * A step this close to the border can only be that connector — a genuine + * obstacle dodge bends much further out — so moving the terminal onto the + * channel row cannot introduce an overlap. The rest of the route is untouched. + */ +const TERMINAL_JOG_MAX = 16; + +/** + * How far from the node the step may sit and still count as the connector. + * + * Size alone does not identify a port-to-channel step: a small step a long way + * down the route is a routing decision, and collapsing it drags the port along + * for no reason. On the sample corpus every genuine connector turns 20–25 from + * the border, while the ones worth leaving alone turn at 48, 112 and 173 — one + * of which slid a port 15px into an occupied row and produced a crossing that + * was not there before. + */ +const TERMINAL_RUN_MAX = 30; + +/** + * Tolerance for deciding whether a segment counts as axis-aligned, and whether + * a step is a step at all. + * + * Deliberately much smaller than the shared `EPS` of 1, which exists for "is + * this point on a border" and is far too coarse here: ELK routinely leaves a + * sub-pixel step between the port row and the channel row, and at `EPS` those + * are not even recognised as segments. They still paint as two rounded corners + * stacked on each other, which is the artefact this pass removes — an + * `infra -> auth` edge stepped 0.858 and rendered exactly that way. + */ +const JOG_EPS = 0.01; + +/** Axis of an axis-aligned segment: `h`, `v`, or undefined when diagonal. */ +function axisOf(a: P, b: P): 'h' | 'v' | undefined { + const dx = Math.abs(b.x - a.x); + const dy = Math.abs(b.y - a.y); + if (dx > JOG_EPS && dy <= JOG_EPS) { + return 'h'; + } + if (dy > JOG_EPS && dx <= JOG_EPS) { + return 'v'; + } + return undefined; +} + +/** + * Straighten the port-to-channel staircase at either end of a clipped route, + * leaving both ports where they are. + * + * Returns the original array when nothing applies, so callers can compare by + * identity. + */ +export function straightenTerminalJogs(points: P[]): P[] { + let pts = straightenFront(points) ?? points; + const reversed = [...pts].reverse(); + const fixedEnd = straightenFront(reversed); + if (fixedEnd) { + pts = fixedEnd.reverse(); + } + return pts; +} + +/** + * Straighten the staircase at the front of `pts`, or return null when it does + * not apply. + * + * The step is removed by pulling the CHANNEL onto the port's row, never by + * pulling the port onto the channel's. Moving the port slides the attachment + * along the node border, and a node whose other edges are still at their spread + * positions then looks lopsided — the reason this was rewritten. Moving the + * channel instead keeps every port exactly where ELK placed it, at the cost of + * displacing one run, which is why the caller checks the result for crossings. + * + * The run is only moved when the point after it is not the far terminal, since + * that would move the other end's port and reintroduce the same problem there. + */ +function straightenFront(pts: P[]): P[] | null { + if (pts.length < 5) { + return null; + } + const [p0, p1, p2, p3] = pts; + const axis = axisOf(p0, p1); + if (!axis || axisOf(p2, p3) !== axis || axisOf(p1, p2) !== (axis === 'h' ? 'v' : 'h')) { + return null; + } + // The step has to be next to the node to be the port-to-channel connector. + if (Math.hypot(p1.x - p0.x, p1.y - p0.y) > TERMINAL_RUN_MAX) { + return null; + } + const jog = axis === 'h' ? Math.abs(p2.y - p1.y) : Math.abs(p2.x - p1.x); + if (jog < JOG_EPS || jog > TERMINAL_JOG_MAX) { + return null; + } + // The route has to keep travelling the same way after the step, otherwise + // this is a real turn rather than a connector. + const forward = + axis === 'h' + ? Math.sign(p1.x - p0.x) === Math.sign(p3.x - p2.x) + : Math.sign(p1.y - p0.y) === Math.sign(p3.y - p2.y); + if (!forward) { + return null; + } + // The WHOLE run has to move, not just its first segment: the channel carries + // on past p3 until the route turns, and shifting only part of it leaves a + // diagonal where the moved and unmoved halves meet. + let last = 3; + while (last + 1 < pts.length && axisOf(pts[last], pts[last + 1]) === axis) { + last++; + } + // The far terminal must not be inside the run — moving it would drag the + // other end's port, which is the thing this avoids. + if (last === pts.length - 1) { + return null; + } + + // No border check is needed: the port is untouched, so it stays exactly where + // ELK put it, and the run moves onto that same row — which the first segment + // already occupied on its way out of the node. + const moved = [...pts]; + for (let i = 2; i <= last; i++) { + moved[i] = axis === 'h' ? { x: pts[i].x, y: p0.y } : { x: p0.x, y: pts[i].y }; + } + // p1 and p2 are now collinear with p0 and the rest of the run. + moved.splice(1, 2); + return moved; +} + +/** Do two axis-aligned segments cross at a point interior to both? */ +function segmentsCrossStrict(a1: P, a2: P, b1: P, b2: P): boolean { + const side = (o: P, p: P, q: P) => (p.x - o.x) * (q.y - o.y) - (p.y - o.y) * (q.x - o.x); + const d1 = side(b1, b2, a1); + const d2 = side(b1, b2, a2); + const d3 = side(a1, a2, b1); + const d4 = side(a1, a2, b2); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); +} + +/** How many times one polyline crosses another. */ +function crossingCount(a: P[], b: P[]): number { + let n = 0; + for (let i = 0; i < a.length - 1; i++) { + for (let j = 0; j < b.length - 1; j++) { + if (segmentsCrossStrict(a[i], a[i + 1], b[j], b[j + 1])) { + n++; + } + } + } + return n; +} + +/** + * Straighten the port-to-channel step on every edge that has one, but only + * where doing so does not buy a crossing. + * + * Runs once over the finished layout rather than per edge, because the decision + * needs the other edges: the step is removed by displacing one of this edge's + * runs onto the port's row, and that run can land in a lane something else + * already occupies. Trading a barely-visible step for a new crossing is a bad + * deal, so an edge that would cause one is left exactly as ELK routed it. + */ +function straightenEdgeTerminals(edges: Edge[]): void { + const routes = edges.map((edge) => (edge as { points?: P[] }).points ?? []); + + for (const [index, edge] of edges.entries()) { + const original = routes[index]; + if (original.length < 5) { + continue; + } + const candidate = straightenTerminalJogs(original); + if (candidate === original) { + continue; + } + + let before = 0; + let after = 0; + for (const [other, route] of routes.entries()) { + if (other === index || route.length < 2) { + continue; + } + before += crossingCount(original, route); + after += crossingCount(candidate, route); + } + if (after > before) { + continue; + } + + (edge as { points?: P[] }).points = candidate; + routes[index] = candidate; + } +} + function applyElkEdgeLayout( data4Layout: LayoutData, graph: ElkLayoutResult, @@ -1016,6 +1364,8 @@ function applyElkEdgeLayout( log: ElkLayoutContext['log'] ): void { const edgeById = new Map(data4Layout.edges.map((edge) => [edge.id, edge])); + // Opt-out rather than opt-in: the step this removes is never intentional. + const straightenEdges = data4Layout.config.elk?.straightenEdges !== false; graph.edges?.forEach((edge) => { const layoutEdge = edgeById.get(edge.id); @@ -1088,8 +1438,9 @@ function applyElkEdgeLayout( points.push({ x: endNode.x, y: endNode.y }); } + const clipped = sanitizeElkEdgePoints(points, startNode, endNode, log); layoutEdge.points = ensureEndMarkerSegmentLength( - sanitizeElkEdgePoints(points, startNode, endNode, log), + clipped, boundsFor(endNode), getEndMarkerPathOffset(layoutEdge), log @@ -1102,6 +1453,10 @@ function applyElkEdgeLayout( layoutEdge.y = label.y + offset.y + label.height / 2; } }); + + if (straightenEdges) { + straightenEdgeTerminals(data4Layout.edges); + } } function createEdgePointsFromSection(section: any, offset: { x: number; y: number }): P[] { @@ -1504,6 +1859,33 @@ function applyEndIntersectionIfNeeded( } } +/** + * Attachment point for the terminal at `portIndex`, on the axis the edge + * departs along. + * + * `step` is +1 at the start of the polyline and -1 at the end, i.e. the + * direction that walks AWAY from the node, which is what gives the departure + * direction. Groups are excluded: their frame already is their outline, and the + * caller has its own on-border handling for them. + */ +function attachAlongDepartureAxis( + node: NodeWithVertex, + bounds: RectLike, + points: P[], + portIndex: number, + step: 1 | -1 +): P | null { + if (node?.isGroup) { + return null; + } + const port = points[portIndex]; + const next = points[portIndex + step]; + if (!port || !next) { + return null; + } + return outlineAttachPoint(node, bounds, port, next); +} + function cutter2( startNode: NodeWithVertex, endNode: NodeWithVertex, @@ -1534,12 +1916,12 @@ function cutter2( if (firstOutsideStartIndex !== -1) { const outsidePointForStart = points[firstOutsideStartIndex]; - const startIntersection = computeNodeIntersection( - startNode, - startBounds, - outsidePointForStart, - startCenter - ); + const startIntersection = + // Prefer an attachment on the edge's own departure axis; see + // `outlineAttachPoint`. Falls back to the centre-ray intersection, which + // is all a non-axis-aligned or shapeless endpoint can offer. + attachAlongDepartureAxis(startNode, startBounds, points, firstOutsideStartIndex, 1) ?? + computeNodeIntersection(startNode, startBounds, outsidePointForStart, startCenter); log.debug('UIO cutter2: start intersection', startIntersection); replaceEndpoint(points, 'start', startIntersection); } @@ -1561,12 +1943,9 @@ function cutter2( } if (outsidePointForEnd) { - const endIntersection = computeNodeIntersection( - endNode, - endBounds, - outsidePointForEnd, - endCenter - ); + const endIntersection = + attachAlongDepartureAxis(endNode, endBounds, points, outsideIndexForEnd, -1) ?? + computeNodeIntersection(endNode, endBounds, outsidePointForEnd, endCenter); log.debug('UIO cutter2: end intersection', { endIntersection, outsideIndexForEnd }); replaceEndpoint(points, 'end', endIntersection); } From a24488fef20c6878e628d015873caaec43b43679 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:04:38 +0000 Subject: [PATCH 10/31] [autofix.ci] apply automated fixes --- .../defaultConfig/variables/configKeys.md | 2 +- docs/config/setup/mermaid/README.md | 3 + .../mermaid/functions/applyLineJumpsToSvg.md | 39 ++++ .../setup/mermaid/interfaces/EdgeGeom.md | 65 +++++++ .../mermaid/interfaces/LineJumpConfig.md | 37 ++++ .../setup/mermaid/interfaces/Mermaid.md | 32 ++-- .../setup/mermaid/interfaces/MermaidConfig.md | 175 +++++++++++++----- .../setup/mermaid/interfaces/RunOptions.md | 10 +- .../config/setup/mermaid/variables/default.md | 2 +- 9 files changed, 292 insertions(+), 73 deletions(-) create mode 100644 docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md create mode 100644 docs/config/setup/mermaid/interfaces/EdgeGeom.md create mode 100644 docs/config/setup/mermaid/interfaces/LineJumpConfig.md diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 98cba0ae016..1e2a647b22f 100644 --- a/docs/config/setup/defaultConfig/variables/configKeys.md +++ b/docs/config/setup/defaultConfig/variables/configKeys.md @@ -12,4 +12,4 @@ > `const` **configKeys**: `Set`<`string`> -Defined in: [packages/mermaid/src/defaultConfig.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L343) +Defined in: [packages/mermaid/src/defaultConfig.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L354) diff --git a/docs/config/setup/mermaid/README.md b/docs/config/setup/mermaid/README.md index d76aef995e4..4d3dc34678c 100644 --- a/docs/config/setup/mermaid/README.md +++ b/docs/config/setup/mermaid/README.md @@ -18,9 +18,11 @@ - [CommonLayoutRenderContext](interfaces/CommonLayoutRenderContext.md) - [CommonLayoutRendererDefinition](interfaces/CommonLayoutRendererDefinition.md) - [DetailedError](interfaces/DetailedError.md) +- [EdgeGeom](interfaces/EdgeGeom.md) - [ExternalDiagramDefinition](interfaces/ExternalDiagramDefinition.md) - [LayoutData](interfaces/LayoutData.md) - [LayoutLoaderDefinition](interfaces/LayoutLoaderDefinition.md) +- [LineJumpConfig](interfaces/LineJumpConfig.md) - [Mermaid](interfaces/Mermaid.md) - [MermaidConfig](interfaces/MermaidConfig.md) - [ParseOptions](interfaces/ParseOptions.md) @@ -46,6 +48,7 @@ ## Functions +- [applyLineJumpsToSvg](functions/applyLineJumpsToSvg.md) - [clearLayoutRenderState](functions/clearLayoutRenderState.md) - [createCommonLayoutRenderer](functions/createCommonLayoutRenderer.md) - [defaultMeasureLayout](functions/defaultMeasureLayout.md) diff --git a/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md b/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md new file mode 100644 index 00000000000..55b348edde2 --- /dev/null +++ b/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md @@ -0,0 +1,39 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md). + +[**mermaid**](../../README.md) + +--- + +# Function: applyLineJumpsToSvg() + +> **applyLineJumpsToSvg**(`edgePathsGroup`, `edges`, `config`): `void` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:644](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L644) + +Patches the rendered SVG paths in `edgePathsGroup` for any edges that +cross. The true geometry is read from each path's `data-points` attribute +(written by edges.js at render time) so the rewrite's endpoints match +exactly what was originally rendered. Edges whose curve is a true +smoothing curve (`basis`, `monotoneX`, …) are skipped. + +## Parameters + +### edgePathsGroup + +`D3Selection`<`SVGGElement`> + +### edges + +[`EdgeGeom`](../interfaces/EdgeGeom.md)\[] + +### config + +[`LineJumpConfig`](../interfaces/LineJumpConfig.md) + +## Returns + +`void` diff --git a/docs/config/setup/mermaid/interfaces/EdgeGeom.md b/docs/config/setup/mermaid/interfaces/EdgeGeom.md new file mode 100644 index 00000000000..26d14f335bb --- /dev/null +++ b/docs/config/setup/mermaid/interfaces/EdgeGeom.md @@ -0,0 +1,65 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/interfaces/EdgeGeom.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/interfaces/EdgeGeom.md). + +[**mermaid**](../../README.md) + +--- + +# Interface: EdgeGeom + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:56](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L56) + +## Properties + +### arrowTypeEnd? + +> `optional` **arrowTypeEnd**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:72](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L72) + +Arrow type at the end (last point). + +--- + +### arrowTypeStart? + +> `optional` **arrowTypeStart**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L70) + +Arrow type at the start (first point) — used to apply marker offset so +the rewritten path's endpoint matches the original rendered geometry and +the arrow marker orients correctly. + +--- + +### curve? + +> `optional` **curve**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:66](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L66) + +Optional curve hint matching `edge.curve` from the rendering layer. +When set, line jumps are only applied for orthogonal-friendly curves +(`'linear'`, `'rounded'`, `'step'`, `'stepBefore'`, `'stepAfter'`, or +undefined). Other curves (basis, monotoneX, …) are skipped to avoid +corrupting smoothed geometry. + +--- + +### id + +> **id**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:57](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L57) + +--- + +### points + +> **points**: `Point`\[] + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:58](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L58) diff --git a/docs/config/setup/mermaid/interfaces/LineJumpConfig.md b/docs/config/setup/mermaid/interfaces/LineJumpConfig.md new file mode 100644 index 00000000000..798fe18338c --- /dev/null +++ b/docs/config/setup/mermaid/interfaces/LineJumpConfig.md @@ -0,0 +1,37 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/interfaces/LineJumpConfig.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/interfaces/LineJumpConfig.md). + +[**mermaid**](../../README.md) + +--- + +# Interface: LineJumpConfig + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:75](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L75) + +## Properties + +### enabled + +> **enabled**: `boolean` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:76](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L76) + +--- + +### jumpRadius + +> **jumpRadius**: `number` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:77](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L77) + +--- + +### jumpStyle + +> **jumpStyle**: `"arc"` | `"gap"` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:78](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L78) diff --git a/docs/config/setup/mermaid/interfaces/Mermaid.md b/docs/config/setup/mermaid/interfaces/Mermaid.md index 1671a976d69..a99094d7ca7 100644 --- a/docs/config/setup/mermaid/interfaces/Mermaid.md +++ b/docs/config/setup/mermaid/interfaces/Mermaid.md @@ -10,7 +10,7 @@ # Interface: Mermaid -Defined in: [packages/mermaid/src/mermaid.ts:446](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L446) +Defined in: [packages/mermaid/src/mermaid.ts:451](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L451) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:446](https://github.com/mermaid-js/ > **contentLoaded**: () => `void` -Defined in: [packages/mermaid/src/mermaid.ts:464](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L464) +Defined in: [packages/mermaid/src/mermaid.ts:469](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L469) \##contentLoaded Callback function that is called when page is loaded. This functions fetches configuration for mermaid rendering and calls init for rendering the mermaid diagrams on the @@ -34,7 +34,7 @@ page. > **detectType**: (`text`, `config?`) => `string` -Defined in: [packages/mermaid/src/mermaid.ts:466](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L466) +Defined in: [packages/mermaid/src/mermaid.ts:471](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L471) Detects the type of the graph text. @@ -90,7 +90,7 @@ A graph definition key > **getRegisteredDiagramsMetadata**: () => `Pick`<[`ExternalDiagramDefinition`](ExternalDiagramDefinition.md), `"id"`>\[] -Defined in: [packages/mermaid/src/mermaid.ts:468](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L468) +Defined in: [packages/mermaid/src/mermaid.ts:473](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L473) Gets the metadata for all registered diagrams. Currently only the id is returned. @@ -107,7 +107,7 @@ An array of objects with the id of the diagram. > **init**: (`config?`, `nodes?`, `callback?`) => `Promise`<`void`> -Defined in: [packages/mermaid/src/mermaid.ts:459](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L459) +Defined in: [packages/mermaid/src/mermaid.ts:464](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L464) ## init @@ -155,7 +155,7 @@ Use [initialize](#initialize) and [run](#run) instead. > **initialize**: (`config`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:463](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L463) +Defined in: [packages/mermaid/src/mermaid.ts:468](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L468) Used to set configurations for mermaid. This function should be called before the run function. @@ -178,7 +178,7 @@ Configuration object for mermaid. > **mermaidAPI**: `Readonly`<{ `defaultConfig`: [`MermaidConfig`](MermaidConfig.md); `getConfig`: () => [`MermaidConfig`](MermaidConfig.md); `getDiagramFromText`: (`text`, `metadata`) => `Promise`<`Diagram`>; `getSiteConfig`: () => [`MermaidConfig`](MermaidConfig.md); `globalReset`: () => `void`; `initialize`: (`userOptions`) => `void`; `parse`: {(`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)>; (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)>; }; `render`: (`id`, `text`, `svgContainingElement?`) => `Promise`<[`RenderResult`](RenderResult.md)>; `reset`: () => `void`; `setConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); `updateSiteConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); }> -Defined in: [packages/mermaid/src/mermaid.ts:453](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L453) +Defined in: [packages/mermaid/src/mermaid.ts:458](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L458) **`Internal`** @@ -192,7 +192,7 @@ Use [parse](#parse) and [render](#render) instead. Please [open a discussion](ht > **parse**: {(`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)>; (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)>; } -Defined in: [packages/mermaid/src/mermaid.ts:454](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L454) +Defined in: [packages/mermaid/src/mermaid.ts:459](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L459) #### Call Signature @@ -268,7 +268,7 @@ Error if the diagram is invalid and parseOptions.suppressErrors is false or not > `optional` **parseError**: [`ParseErrorFunction`](../type-aliases/ParseErrorFunction.md) -Defined in: [packages/mermaid/src/mermaid.ts:448](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L448) +Defined in: [packages/mermaid/src/mermaid.ts:453](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L453) --- @@ -276,7 +276,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:448](https://github.com/mermaid-js/ > **registerExternalDiagrams**: (`diagrams`, `opts`) => `Promise`<`void`> -Defined in: [packages/mermaid/src/mermaid.ts:462](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L462) +Defined in: [packages/mermaid/src/mermaid.ts:467](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L467) Used to register external diagram types. @@ -306,7 +306,7 @@ If opts.lazyLoad is false, the diagrams will be loaded immediately. > **registerIconPacks**: (`iconLoaders`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:467](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L467) +Defined in: [packages/mermaid/src/mermaid.ts:472](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L472) #### Parameters @@ -324,7 +324,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:467](https://github.com/mermaid-js/ > **registerLayoutLoaders**: (`loaders`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:461](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L461) +Defined in: [packages/mermaid/src/mermaid.ts:466](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L466) #### Parameters @@ -342,7 +342,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:461](https://github.com/mermaid-js/ > **render**: (`id`, `text`, `svgContainingElement?`) => `Promise`<[`RenderResult`](RenderResult.md)> -Defined in: [packages/mermaid/src/mermaid.ts:455](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L455) +Defined in: [packages/mermaid/src/mermaid.ts:460](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L460) #### Parameters @@ -374,7 +374,7 @@ Deprecated for external use. > **run**: (`options`) => `Promise`<`void`> -Defined in: [packages/mermaid/src/mermaid.ts:460](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L460) +Defined in: [packages/mermaid/src/mermaid.ts:465](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L465) ## run @@ -418,7 +418,7 @@ Optional runtime configs > **setParseErrorHandler**: (`parseErrorHandler`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:465](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L465) +Defined in: [packages/mermaid/src/mermaid.ts:470](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L470) ## setParseErrorHandler Alternative to directly setting parseError using: @@ -449,4 +449,4 @@ New parseError() callback. > **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/mermaid.ts:447](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L447) +Defined in: [packages/mermaid/src/mermaid.ts:452](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L452) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index 400abd3371b..bd6f807341e 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/config.type.ts:66](https://github.com/mermaid- > `optional` **agentflow**: `AgentflowDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:236](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L236) +Defined in: [packages/mermaid/src/config.type.ts:313](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L313) --- @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/config.type.ts:236](https://github.com/mermaid > `optional` **altFontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:174](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L174) +Defined in: [packages/mermaid/src/config.type.ts:251](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L251) --- @@ -34,7 +34,7 @@ Defined in: [packages/mermaid/src/config.type.ts:174](https://github.com/mermaid > `optional` **architecture**: `ArchitectureDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:248](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L248) +Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325) --- @@ -42,7 +42,7 @@ Defined in: [packages/mermaid/src/config.type.ts:248](https://github.com/mermaid > `optional` **arrowMarkerAbsolute**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:193](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L193) +Defined in: [packages/mermaid/src/config.type.ts:270](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L270) Controls whether or arrow markers in html code are absolute paths or anchors. This matters if you are using base tag settings. @@ -53,7 +53,7 @@ This matters if you are using base tag settings. > `optional` **block**: `BlockDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:256](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L256) +Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333) --- @@ -61,7 +61,7 @@ Defined in: [packages/mermaid/src/config.type.ts:256](https://github.com/mermaid > `optional` **c4**: `C4DiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:253](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L253) +Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L330) --- @@ -69,7 +69,7 @@ Defined in: [packages/mermaid/src/config.type.ts:253](https://github.com/mermaid > `optional` **class**: `ClassDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:241](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L241) +Defined in: [packages/mermaid/src/config.type.ts:318](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L318) --- @@ -77,7 +77,7 @@ Defined in: [packages/mermaid/src/config.type.ts:241](https://github.com/mermaid > `optional` **cynefin**: `CynefinDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:263](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L263) +Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340) --- @@ -85,7 +85,7 @@ Defined in: [packages/mermaid/src/config.type.ts:263](https://github.com/mermaid > `optional` **darkMode**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:158](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L158) +Defined in: [packages/mermaid/src/config.type.ts:235](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L235) --- @@ -93,7 +93,7 @@ Defined in: [packages/mermaid/src/config.type.ts:158](https://github.com/mermaid > `optional` **deterministicIds**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:226](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L226) +Defined in: [packages/mermaid/src/config.type.ts:303](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L303) This option controls if the generated ids of nodes in the SVG are generated randomly or based on a seed. @@ -109,7 +109,7 @@ should not change unless content is changed. > `optional` **deterministicIDSeed**: `string` -Defined in: [packages/mermaid/src/config.type.ts:233](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L233) +Defined in: [packages/mermaid/src/config.type.ts:310](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L310) This option is the optional seed for deterministic ids. If set to `undefined` but deterministicIds is `true`, a simple number iterator is used. @@ -121,7 +121,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:265](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L265) +Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342) --- @@ -139,7 +139,7 @@ Preserves the order of nodes and edges in the model file if this does not lead t #### cycleBreakingStrategy? -> `optional` **cycleBreakingStrategy**: `"GREEDY"` | `"DEPTH_FIRST"` | `"INTERACTIVE"` | `"MODEL_ORDER"` | `"GREEDY_MODEL_ORDER"` +> `optional` **cycleBreakingStrategy**: `"INTERACTIVE"` | `"GREEDY"` | `"DEPTH_FIRST"` | `"MODEL_ORDER"` | `"GREEDY_MODEL_ORDER"` This strategy decides how to find cycles in the graph and deciding which edges need adjustment to break loops. @@ -159,6 +159,41 @@ When a flow loops back on itself (a back-edge to an earlier node), ELK's degree- Only applies when the cyclic flow has no node without incoming edges: if the loop is fed from outside (e.g. a start node pointing into it), that component already has a natural source and nothing is pinned. Detection is also scoped per container, so cycles that cross a subgraph boundary are not detected. +#### layeringLayerBound? + +> `optional` **layeringLayerBound**: `number` + +Elk specific option capping how many nodes COFFMAN_GRAHAM will put in +one layer. Ignored by every other layering strategy. Lower values give +a taller, narrower drawing. + +#### layeringStrategy? + +> `optional` **layeringStrategy**: `"NETWORK_SIMPLEX"` | `"LONGEST_PATH"` | `"LONGEST_PATH_SOURCE"` | `"COFFMAN_GRAHAM"` | `"MIN_WIDTH"` | `"STRETCH_WIDTH"` | `"INTERACTIVE"` + +Elk specific option deciding which layer each node is assigned to — the +column in a left-to-right diagram, the row in a top-down one. This is +the coarsest of the three placement decisions, so changing it moves +nodes further than anything else short of altering spacing. + +NETWORK_SIMPLEX aims for the fewest long edges. LONGEST_PATH pushes +every node as late as it can go. COFFMAN_GRAHAM bounds how many nodes +share a layer, giving a more even, block-like shape on wide graphs. +MIN_WIDTH and STRETCH_WIDTH trade edge length for a narrower or wider +drawing. INTERACTIVE honours positions already on the nodes. + +#### lineHops? + +> `optional` **lineHops**: `boolean` | `"arc"` | `"gap"` + +Renders edge crossings as small arcs ("hops") or visible gaps, so that +it is clear which line passes over which where two edges meet. + +The edge that gives way loses its corner rounding for the segment +carrying the hop, which is the trade for a readable crossing. Curved +edges are skipped rather than rewritten, to avoid corrupting their +geometry. Set to `false` to draw plain crossings. + #### mergeEdges? > `optional` **mergeEdges**: `boolean` @@ -178,13 +213,53 @@ NONE picks the alignment with the smallest height. Elk specific option affecting how nodes are placed. +#### preset? + +> `optional` **preset**: `"legacy"` | `"default"` | `"depthFirst"` + +Named combination of the three options that decide where nodes end up: +layering strategy, node placement strategy and cycle breaking strategy. +They belong to different phases of the layout, so a preset is simply a +named triple rather than a mode with behaviour of its own. + +`default` — network simplex layering, linear segments placement, greedy +model order cycle breaking. Keeps chains of nodes aligned. + +`legacy` — what shipped before presets existed: Brandes-Koepf placement, +which straightens long edges at the cost of that alignment, with ELK's +own greedy cycle breaking. Reproduces the rendering of earlier +versions rather than the defaults their schema advertised. + +`depthFirst` — as `default`, but breaks cycles depth first, which tends +to give shorter back edges on graphs that have many of them. + +Setting `layeringStrategy`, `nodePlacementStrategy` or +`cycleBreakingStrategy` explicitly overrides the preset for that one +option; the rest of the preset still applies. + +#### straightenEdges? + +> `optional` **straightenEdges**: `boolean` + +Straightens an edge that leaves or enters a node with a tiny step. + +ELK spreads an edge's port evenly along a node's side but routes the +edge down a channel whose row rarely lines up with that port exactly, +leaving a staircase of a few pixels right at the border. With rounded +corners the two micro-bends land on top of each other and read as a +kink. Enabling this moves the endpoint onto the channel row — still on +the node's border — and drops the step. + +Only the step next to a node is touched, and only when the edge +continues the same way afterwards, so a real turn is never collapsed. + --- ### er? > `optional` **er**: `ErDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:243](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L243) +Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L320) --- @@ -192,7 +267,7 @@ Defined in: [packages/mermaid/src/config.type.ts:243](https://github.com/mermaid > `optional` **eventmodeling**: `EventModelingDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:257](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L257) +Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334) --- @@ -200,7 +275,7 @@ Defined in: [packages/mermaid/src/config.type.ts:257](https://github.com/mermaid > `optional` **flowchart**: `FlowchartDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:234](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L234) +Defined in: [packages/mermaid/src/config.type.ts:311](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L311) --- @@ -208,7 +283,7 @@ Defined in: [packages/mermaid/src/config.type.ts:234](https://github.com/mermaid > `optional` **fontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L173) +Defined in: [packages/mermaid/src/config.type.ts:250](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L250) Specifies the font to be used in the rendered diagrams. Can be any possible CSS `font-family`. @@ -220,7 +295,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:267](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L267) +Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344) --- @@ -228,7 +303,7 @@ Defined in: [packages/mermaid/src/config.type.ts:267](https://github.com/mermaid > `optional` **forceLegacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215) +Defined in: [packages/mermaid/src/config.type.ts:292](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L292) This option forces Mermaid to rely on KaTeX's own stylesheet for rendering MathML. Due to differences between OS fonts and browser's MathML implementation, this option is recommended if consistent rendering is important. @@ -240,7 +315,7 @@ If set to true, ignores legacyMathML. > `optional` **gantt**: `GanttDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L238) +Defined in: [packages/mermaid/src/config.type.ts:315](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L315) --- @@ -248,7 +323,7 @@ Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid > `optional` **gitGraph**: `GitGraphDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:252](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L252) +Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329) --- @@ -266,7 +341,7 @@ Defines the seed to be used when using handDrawn look. This is important for the > `optional` **htmlLabels**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:166](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L166) +Defined in: [packages/mermaid/src/config.type.ts:243](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L243) Flag for setting whether or not a html tag should be used for rendering labels on nodes and edges. **Note:** Diagram-specific `htmlLabels` settings (e.g., `flowchart.htmlLabels`) are deprecated. @@ -279,7 +354,7 @@ over any diagram-specific settings. > `optional` **ishikawa**: `IshikawaDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:250](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L250) +Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L327) --- @@ -287,7 +362,7 @@ Defined in: [packages/mermaid/src/config.type.ts:250](https://github.com/mermaid > `optional` **journey**: `JourneyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:239](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L239) +Defined in: [packages/mermaid/src/config.type.ts:316](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L316) --- @@ -295,7 +370,7 @@ Defined in: [packages/mermaid/src/config.type.ts:239](https://github.com/mermaid > `optional` **kanban**: `KanbanDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:251](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L251) +Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L328) --- @@ -313,7 +388,7 @@ Defines which layout algorithm to use for rendering the diagram. > `optional` **legacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:208](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L208) +Defined in: [packages/mermaid/src/config.type.ts:285](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L285) This option specifies if Mermaid can expect the dependent to include KaTeX stylesheets for browsers without their own MathML implementation. If this option is disabled and MathML is not supported, the math @@ -326,7 +401,7 @@ fall back to legacy rendering for KaTeX. > `optional` **logLevel**: `0` | `2` | `1` | `"trace"` | `"debug"` | `"info"` | `"warn"` | `"error"` | `"fatal"` | `3` | `4` | `5` -Defined in: [packages/mermaid/src/config.type.ts:179](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L179) +Defined in: [packages/mermaid/src/config.type.ts:256](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L256) This option decides the amount of logging to be used by mermaid. @@ -346,7 +421,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:268](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L268) +Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345) --- @@ -374,7 +449,7 @@ The maximum allowed size of the users text diagram > `optional` **mindmap**: `MindmapDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:249](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L249) +Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L326) --- @@ -382,7 +457,7 @@ Defined in: [packages/mermaid/src/config.type.ts:249](https://github.com/mermaid > `optional` **packet**: `PacketDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:255](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L255) +Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332) --- @@ -390,7 +465,7 @@ Defined in: [packages/mermaid/src/config.type.ts:255](https://github.com/mermaid > `optional` **pie**: `PieDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L244) +Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L321) --- @@ -398,7 +473,7 @@ Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid > `optional` **quadrantChart**: `QuadrantChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) +Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322) --- @@ -406,7 +481,7 @@ Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid > `optional` **radar**: `RadarDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L259) +Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336) --- @@ -414,7 +489,7 @@ Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid > `optional` **railroad**: `RailroadDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:264](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L264) +Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341) --- @@ -422,7 +497,7 @@ Defined in: [packages/mermaid/src/config.type.ts:264](https://github.com/mermaid > `optional` **requirement**: `RequirementDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:247](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L247) +Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L324) --- @@ -430,7 +505,7 @@ Defined in: [packages/mermaid/src/config.type.ts:247](https://github.com/mermaid > `optional` **sankey**: `SankeyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L254) +Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L331) --- @@ -438,7 +513,7 @@ Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid > `optional` **secure**: `string`\[] -Defined in: [packages/mermaid/src/config.type.ts:200](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L200) +Defined in: [packages/mermaid/src/config.type.ts:277](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L277) This option controls which `currentConfig` keys are considered secure and can only be changed via call to `mermaid.initialize`. @@ -450,7 +525,7 @@ This prevents malicious graph directives from overriding a site's default securi > `optional` **securityLevel**: `"strict"` | `"loose"` | `"antiscript"` | `"sandbox"` -Defined in: [packages/mermaid/src/config.type.ts:183](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L183) +Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L260) Level of trust for parsed diagram @@ -460,7 +535,7 @@ Level of trust for parsed diagram > `optional` **sequence**: `SequenceDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L237) +Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L314) --- @@ -468,7 +543,7 @@ Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid > `optional` **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:187](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L187) +Defined in: [packages/mermaid/src/config.type.ts:264](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L264) Dictates whether mermaid starts on Page load @@ -478,7 +553,7 @@ Dictates whether mermaid starts on Page load > `optional` **state**: `StateDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:242](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L242) +Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L319) --- @@ -486,7 +561,7 @@ Defined in: [packages/mermaid/src/config.type.ts:242](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:274](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L274) +Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L351) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -497,7 +572,7 @@ This is useful when you want to control how to handle syntax errors in your appl > `optional` **swimlane**: `SwimlaneDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:235](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L235) +Defined in: [packages/mermaid/src/config.type.ts:312](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L312) --- @@ -532,7 +607,7 @@ Defined in: [packages/mermaid/src/config.type.ts:85](https://github.com/mermaid- > `optional` **timeline**: `TimelineDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:240](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L240) +Defined in: [packages/mermaid/src/config.type.ts:317](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L317) --- @@ -540,7 +615,7 @@ Defined in: [packages/mermaid/src/config.type.ts:240](https://github.com/mermaid > `optional` **treeView**: `TreeViewDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:258](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L258) +Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335) --- @@ -548,7 +623,7 @@ Defined in: [packages/mermaid/src/config.type.ts:258](https://github.com/mermaid > `optional` **usecase**: `UsecaseDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L260) +Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337) --- @@ -556,7 +631,7 @@ Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid > `optional` **venn**: `VennDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:261](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L261) +Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338) --- @@ -564,7 +639,7 @@ Defined in: [packages/mermaid/src/config.type.ts:261](https://github.com/mermaid > `optional` **wardley-beta**: `WardleyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:262](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L262) +Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339) --- @@ -572,7 +647,7 @@ Defined in: [packages/mermaid/src/config.type.ts:262](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:266](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L266) +Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343) --- @@ -580,4 +655,4 @@ Defined in: [packages/mermaid/src/config.type.ts:266](https://github.com/mermaid > `optional` **xyChart**: `XYChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) +Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L323) diff --git a/docs/config/setup/mermaid/interfaces/RunOptions.md b/docs/config/setup/mermaid/interfaces/RunOptions.md index 03fcbfee8cd..5a0eae0610b 100644 --- a/docs/config/setup/mermaid/interfaces/RunOptions.md +++ b/docs/config/setup/mermaid/interfaces/RunOptions.md @@ -10,7 +10,7 @@ # Interface: RunOptions -Defined in: [packages/mermaid/src/mermaid.ts:58](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L58) +Defined in: [packages/mermaid/src/mermaid.ts:63](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L63) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:58](https://github.com/mermaid-js/m > `optional` **nodes**: `ArrayLike`<`HTMLElement`> -Defined in: [packages/mermaid/src/mermaid.ts:66](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L66) +Defined in: [packages/mermaid/src/mermaid.ts:71](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L71) The nodes to render. If this is set, `querySelector` will be ignored. @@ -28,7 +28,7 @@ The nodes to render. If this is set, `querySelector` will be ignored. > `optional` **postRenderCallback**: (`id`) => `unknown` -Defined in: [packages/mermaid/src/mermaid.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L70) +Defined in: [packages/mermaid/src/mermaid.ts:75](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L75) A callback to call after each diagram is rendered. @@ -48,7 +48,7 @@ A callback to call after each diagram is rendered. > `optional` **querySelector**: `string` -Defined in: [packages/mermaid/src/mermaid.ts:62](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L62) +Defined in: [packages/mermaid/src/mermaid.ts:67](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L67) The query selector to use when finding elements to render. Default: `".mermaid"`. @@ -58,6 +58,6 @@ The query selector to use when finding elements to render. Default: `".mermaid"` > `optional` **suppressErrors**: `boolean` -Defined in: [packages/mermaid/src/mermaid.ts:74](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L74) +Defined in: [packages/mermaid/src/mermaid.ts:79](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L79) If `true`, errors will be logged to the console, but not thrown. Default: `false` diff --git a/docs/config/setup/mermaid/variables/default.md b/docs/config/setup/mermaid/variables/default.md index 4a741a64b0c..03da9f13855 100644 --- a/docs/config/setup/mermaid/variables/default.md +++ b/docs/config/setup/mermaid/variables/default.md @@ -12,4 +12,4 @@ > `const` **default**: [`Mermaid`](../interfaces/Mermaid.md) -Defined in: [packages/mermaid/src/mermaid.ts:471](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L471) +Defined in: [packages/mermaid/src/mermaid.ts:476](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L476) From 6f9e8241ac6d55e1be2763ddc467fd7589dd743a Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 17:20:08 +0200 Subject: [PATCH 11/31] fix(elk): halve the edge-approach spacing, which was charged twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subgraph padding this branch set out to even up is still lopsided, because the key that bought the approach run pays for the routing lane as well. On a group with an edge routed down the inside of its frame, `edgeNodeBetweenLayers` is charged once between the nodes and that edge's lane, and again between the lane and the frame. Measured across four values on a six-subgraph diagram, the group's extra width is exactly `36 + 2x`: x=20 padR 76 approach 20 x=25 padR 86 approach 25 x=30 padR 96 approach 30 x=40 padR 116 approach 30 40 was costing 40px of frame more than 20, and buying nothing over 30 — the approach run stops improving there. Nothing separates the two uses. `elk.spacing.edgeNode` at 10 and at 20 leaves both the lane and the frame unchanged, and so does `elk.spacing.edgeEdge`. Buying the same approach out of `spacing.baseValue` instead is worse, not better: approach 20 costs 84px of frame that way against 76px here, and approach 25 costs 99px against 86px. So this key is the cheapest way to buy the approach, and 20 is as low as it goes while keeping every edge clear of its own arrowhead — below it, three edges drop under 15px against a 10px arrowhead. Honest about what is left: this is a balance, not the decoupling the previous commit claimed. A group with a back-edge still carries more frame than one without, 76px against 24px here. Removing the rest would mean not routing that edge inside the frame at all, which is ELK's decision rather than a spacing one. --- packages/mermaid-layout-elk/src/render.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 67398263ed0..22e1431499d 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -307,7 +307,21 @@ export function buildSubgraphLayoutOptions( // own rather than out of `spacing.baseValue` — see the note there. This is // the layered-scoped key; the unscoped `spacing.edgeNodeBetweenLayers` is // not an ELK id at all and setting it does nothing. - 'elk.layered.spacing.edgeNodeBetweenLayers': 40, + // + // 20 is a balance, not a free choice. This value is charged TWICE against a + // group that has an edge routed down the inside of its frame — once between + // the nodes and the edge's lane, and again between that lane and the frame. + // Measured across four values on a six-subgraph diagram, that group's extra + // width came out at exactly `36 + 2x`, so 40 cost 40px of frame more than + // 20 does. It also stops buying anything above 30. + // + // Nothing separates the two uses. `elk.spacing.edgeEdge` and + // `elk.spacing.edgeNode` both leave the lane and the frame unchanged, and + // buying the same approach out of `spacing.baseValue` instead costs MORE + // frame, not less (approach 20 costs 84px that way against 76px here). So + // this key is the cheapest way to buy the approach, and 20 is as low as it + // goes while keeping every edge clear of its own arrowhead. + 'elk.layered.spacing.edgeNodeBetweenLayers': 20, // Separation between edges sharing a lane. Also raised off the base value, // so that lowering the base does not leave parallel edges touching. 'elk.spacing.edgeEdge': 20, From 785ca77ea5b671a9c7ea83b9268924f38af9a578 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 17:28:43 +0200 Subject: [PATCH 12/31] fix(elk): draw subgraph frames an even distance from their contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit got the lopsided padding down from 116px to 76px but could not make it even, because the space is a real routing lane: ELK sizes a container around everything it put inside, edges included, and an edge running against the flow of the layout is routed back around the outside. A group holding one grows on whichever side that edge leaves by. No spacing option separates the lane from the approach run — edgeNode and edgeEdge were both measured and neither moves it. So stop trying to reclaim the space and stop drawing the frame around it. The frame is pulled in to SUBGRAPH_PADDING from the group's own children on the left, right and bottom, and the edge keeps its lane just outside — which is what an edge routed around a group should look like anyway. The top is left exactly as ELK set it. It carries the subgraph's title strip, and there is no way from here to tell how much of that padding is the title and how much is spare, so tightening it risks clipping. Placed between applyElkNodePositions and applyElkEdgeLayout on purpose: boundsFor reads the box the first sets and cutter2 clips an edge that ends on a group against it, so the six edges that attach to a frame in the test diagram follow the frame when it moves. Runs deepest-first, so a parent measures against children already pulled in. Measured on the six-subgraph diagram, every group now reads padL=24 padR=24 padB=24, against 24/76/24 before. The title strips stay put at 48 and 82. --- .changeset/elk-even-subgraph-frames.md | 13 ++ .../src/__tests__/render.spec.ts | 126 ++++++++++++++++++ packages/mermaid-layout-elk/src/render.ts | 90 +++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 .changeset/elk-even-subgraph-frames.md diff --git a/.changeset/elk-even-subgraph-frames.md b/.changeset/elk-even-subgraph-frames.md new file mode 100644 index 00000000000..4f4668ab2af --- /dev/null +++ b/.changeset/elk-even-subgraph-frames.md @@ -0,0 +1,13 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: draw ELK subgraph frames an even distance from their contents. + +A subgraph could sit 76px from its nodes on one side and 24px on the other, with nothing visible in the gap. ELK sizes a container around everything it put inside, edges included, and an edge that runs against the flow of the layout is routed back around the outside — so a group holding one grew on whichever side that edge left by, and a group without one did not. + +The lane is real and the edge still needs it, so the space is not reclaimed. What changes is that the frame is no longer drawn around it: the frame is pulled in to an even distance from the group's own children, and the edge keeps its lane just outside, which is what an edge routed around a group should look like anyway. + +The top is left as ELK set it, since it carries the subgraph's title strip and there is no way to tell how much of that padding is the title and how much is spare. + +**Subgraphs render tighter, and groups that used to be visibly lopsided are now even.** diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index 76a57bb2547..e20efc75ffb 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -5,6 +5,7 @@ import { clearContainerAlgorithmOptions, dir2ElkDirection, ensureEndMarkerSegmentLength, + evenGroupFrames, findCyclicEntryNodes, prepareLayoutForElk, resolveContainerAlgorithm, @@ -779,4 +780,129 @@ describe('clearContainerAlgorithmOptions', () => { expect(options['nodePlacement.strategy']).toBe('BRANDES_KOEPF'); expect(options['nodeLabels.placement']).toBe('[H_CENTER V_TOP, INSIDE]'); }); + + describe('evenGroupFrames', () => { + /** + * Build the two structures the pass reads: the ELK tree (for `isGroup` and + * `children`) and `nodeDb`, whose entries carry the absolute box that + * `applyElkNodePositions` has already written. + */ + function scene(groupBox: { x: number; y: number; w: number; h: number }, kids: number[][]) { + const nodeDb: Record = { + g: { + id: 'g', + isGroup: true, + offset: { posX: groupBox.x, posY: groupBox.y }, + width: groupBox.w, + height: groupBox.h, + }, + }; + const children = kids.map(([x, y, w, h], i) => { + nodeDb[`n${i}`] = { id: `n${i}`, offset: { posX: x, posY: y }, width: w, height: h }; + return { id: `n${i}` }; + }); + const elk = [{ id: 'g', isGroup: true, children, labelData: { width: 0 }, labels: [] }]; + return { elk, nodeDb, layoutState: { nodeDb } as any }; + } + + it('pulls a frame in to even padding when a routing lane inflated one side', () => { + // The reported shape: nodes 100 wide sitting 24 from the left of the frame, + // with the frame running 76 past their right because ELK held a lane there + // for an edge routed back around the outside. + const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 200, h: 148 }, [ + [24, 48, 100, 76], + ]); + + evenGroupFrames(elk, layoutState, new Map()); + + const g = nodeDb.g; + expect(g.offset.posX).toBe(0); + expect(g.width).toBe(148); // 24 + 100 + 24 + // Top is deliberately untouched: it carries the subgraph's title strip. + expect(g.offset.posY).toBe(0); + expect(g.height).toBe(148); // 48 title strip + 76 + 24 + }); + + it('leaves a frame alone when its padding is already even', () => { + const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 148, h: 148 }, [ + [24, 48, 100, 76], + ]); + + evenGroupFrames(elk, layoutState, new Map()); + + expect(nodeDb.g.width).toBe(148); + expect(nodeDb.g.height).toBe(148); + }); + + it('never squeezes a frame narrower than its own title', () => { + // A one-node group under a long title. Pulling in to the node would cut the + // title off, so the floor wins and the frame stays centred on its contents. + const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 300, h: 148 }, [ + [24, 48, 40, 76], + ]); + elk[0].labelData = { width: 200 }; + + evenGroupFrames(elk, layoutState, new Map()); + + expect(nodeDb.g.width).toBe(200); + // Centred on the node's midpoint at x=44, so 44 - 100. + expect(nodeDb.g.offset.posX).toBe(-56); + }); + + it('measures a parent against children it has already pulled in', () => { + // Nested groups: the inner frame is inflated by 76 on the right and the + // outer one wraps it. Going deepest-first means the outer frame measures + // the tightened inner box, not the original. + const nodeDb: Record = { + outer: { + id: 'outer', + isGroup: true, + offset: { posX: 0, posY: 0 }, + width: 300, + height: 220, + }, + inner: { + id: 'inner', + isGroup: true, + offset: { posX: 24, posY: 48 }, + width: 200, + height: 148, + }, + leaf: { id: 'leaf', offset: { posX: 48, posY: 96 }, width: 100, height: 76 }, + }; + const elk = [ + { + id: 'outer', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [ + { + id: 'inner', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [{ id: 'leaf' }], + }, + ], + }, + ]; + + evenGroupFrames(elk, { nodeDb } as any, new Map()); + + expect(nodeDb.inner.width).toBe(148); // 24 + 100 + 24 + expect(nodeDb.outer.width).toBe(196); // 24 + 148 + 24 + }); + + it('skips a group with no children rather than collapsing it', () => { + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 200, height: 100 }, + }; + + evenGroupFrames([{ id: 'g', isGroup: true, children: [] }], { nodeDb } as any, new Map()); + + expect(nodeDb.g.width).toBe(200); + expect(nodeDb.g.height).toBe(100); + }); + }); }); diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 22e1431499d..9480e5c1849 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -1097,9 +1097,99 @@ function applyElkLayoutResult( ): void { const nodeById = new Map(data4Layout.nodes.map((node) => [node.id, node])); applyElkNodePositions(graph.children ?? [], layoutState, nodeById, 0, 0, 0, log); + // Between positions and edges on purpose: `boundsFor` reads the box set + // above, and `cutter2` clips an edge that ends on a group against it, so an + // edge attaching to a frame follows the frame when it moves. + evenGroupFrames(graph.children ?? [], layoutState, nodeById); applyElkEdgeLayout(data4Layout, graph, layoutState, log); } +/** + * Sit each group's frame an even distance from its own contents. + * + * ELK sizes a container around everything it put inside, edges included. An + * edge that runs against the flow of the layout gets routed back around the + * outside, and when that happens inside a frame the frame grows to hold the + * lane — on one side only, since that is where the edge leaves. The result is a + * group with 76px of space on the right and 24px on the left, which reads as a + * mistake because nothing visible occupies it. + * + * The lane is real and the edge still needs it, so the fix is not to reclaim + * the space but to stop drawing the frame around it. The frame is pulled in to + * `SUBGRAPH_PADDING` from the children on the left, right and bottom, and the + * edge keeps its lane just outside — which is what an edge routed around a + * group should look like anyway. + * + * The top is left exactly as ELK set it. It carries the subgraph's title strip, + * and there is no way from here to tell how much of that padding is the label + * and how much is spare, so tightening it risks clipping the title. + * + * Runs deepest-first, so a parent measures against children that have already + * been pulled in rather than against their original boxes. + */ +export function evenGroupFrames( + elkNodes: any[], + layoutState: ElkLayoutState, + nodeById: Map +): void { + for (const elkNode of elkNodes) { + if (!elkNode?.isGroup) { + continue; + } + const children = elkNode.children ?? []; + evenGroupFrames(children, layoutState, nodeById); + + const group = layoutState.nodeDb[elkNode.id]; + const boxes = children + .map((child: { id: string }) => layoutState.nodeDb[child.id]) + .filter((child: NodeWithVertex | undefined) => child?.offset && child.width && child.height); + if (!group?.offset || boxes.length === 0) { + continue; + } + + const left = Math.min(...boxes.map((b: NodeWithVertex) => b.offset!.posX)) - SUBGRAPH_PADDING; + const right = + Math.max(...boxes.map((b: NodeWithVertex) => b.offset!.posX + b.width!)) + SUBGRAPH_PADDING; + const bottom = + Math.max(...boxes.map((b: NodeWithVertex) => b.offset!.posY + b.height!)) + SUBGRAPH_PADDING; + const top = group.offset.posY; + + // A frame narrower than its own title would cut the title off. Both the + // drawn rect and `getEffectiveGroupWidth` have their own idea of the floor, + // so honour the larger and keep the frame centred on its contents. + const labelFloor = Math.max( + elkNode.labelData?.width ?? 0, + (elkNode.labels?.[0]?.width ?? 0) + (elkNode.padding ?? 0) + ); + let x = left; + let width = right - left; + if (width < labelFloor) { + x -= (labelFloor - width) / 2; + width = labelFloor; + } + const height = bottom - top; + if (height <= 0 || width <= 0) { + continue; + } + + group.offset.posX = x; + group.offset.width = width; + group.offset.height = height; + group.width = width; + group.height = height; + group.x = x + width / 2; + group.y = top + height / 2; + + const layoutNode = nodeById.get(elkNode.id); + if (layoutNode) { + layoutNode.x = group.x; + layoutNode.y = group.y; + layoutNode.width = Math.max(width, elkNode.labelData?.width ?? 0); + layoutNode.height = height; + } + } +} + function applyElkNodePositions( nodeArray: any[], layoutState: ElkLayoutState, From 5e8f82242b26f2afcb70c8f9adb028e8c289711e Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 17:35:27 +0200 Subject: [PATCH 13/31] fix(elk): address review on #8152 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog accuracy: - Three changesets stated different things about node placement, and they concatenate into one changelog. elk-non-rect-attachment.md described an intermediate branch state that never ships (NETWORK_SIMPLEX as the root default, with BRANDES_KOEPF as the way back), including corpus evidence measured against that state. What actually ships is preset: 'default' with nodePlacementStrategy: undefined, so the preset supplies LINEAR_SEGMENTS. The net change from the last release is now stated once, in elk-layout-presets.md, and the subgraph-level NETWORK_SIMPLEX is named there as the separate container setting it is. - Swimlanes call applyLineJumpsToSvg (adjustLayout.ts:25) and have hops on today, so both lineJump changes move swimlane output for people not using ELK at all. line-hop-corner-clearance.md now says so up front. intersect-line-half-pixel.md likewise now leads with the fact that it reaches every layout, since a dagre flowchart with a decision diamond is affected as much as an ELK one. The bias description was wrong, in the comments and the changeset. The original nudged the numerator AWAY FROM ZERO: const offset = Math.abs(denom / 2); x = num < 0 ? (num - offset) / denom : (num + offset) / denom; so the displacement is 0.5 * sign(num) * sign(denom), not a constant +0.5. The magnitude is always half a unit but the sign is per axis, because num is computed separately for x and y while denom is shared. That also explains the old question.ts compensation properly: subtracting a flat 0.5 from both axes only cancelled the bias when both signs came out positive, and doubled the error to a full unit when they did not. A test now pins it, walking the same crossing through all four sign combinations; it fails on the old code. Robustness: - resolveElkPreset used an indexed lookup, so a preset named __proto__ returned Object.prototype — truthy, so the fallback never fired and every strategy read off it came back undefined. The schema enum guards config but not directives or programmatic config. Object.hasOwn now. - applyLineJumpsToSvg built a selector per edge from an author-controlled id. CSS.escape is not guaranteed outside a browser and the raw-id fallback turns a trailing backslash into a SyntaxError that aborts the render. Paths are indexed by reading data-id instead, so no selector is built. - lineHops.ts had two casts that asserted rather than described: a `never` on the paint groups and a trailing `as EdgeGeom[]`. Replaced with a named interface deriving the selection type from applyLineJumpsToSvg's own signature, and a type predicate in the filter so the map needs no assertion. JUMP_RADIUS now records why the radius is fixed while the style is configurable. - OUTLINE_RAY_STEPS 40 -> 20. Each step costs a node.intersect() call and runs for both endpoints of every edge. 20 steps take a 200px bracket to ~2e-4px, four orders of magnitude below anything renderable. Geometry over the test diagram is unchanged. --- .changeset/elk-layout-presets.md | 4 +- .changeset/elk-non-rect-attachment.md | 4 -- .changeset/elk-subgraph-spacing-split.md | 2 +- .changeset/intersect-line-half-pixel.md | 8 ++-- .changeset/line-hop-corner-clearance.md | 2 +- packages/mermaid-layout-elk/src/geometry.ts | 12 ++++- packages/mermaid-layout-elk/src/lineHops.ts | 47 +++++++++++++------ packages/mermaid-layout-elk/src/render.ts | 14 +++++- .../intersect/intersect-line.js | 31 ++++++++---- .../intersect/intersect-line.spec.ts | 47 +++++++++++++++++++ .../rendering-elements/lineJump.ts | 16 ++++++- .../rendering-elements/shapes/question.ts | 11 +++-- 12 files changed, 154 insertions(+), 44 deletions(-) diff --git a/.changeset/elk-layout-presets.md b/.changeset/elk-layout-presets.md index db21c7f87b4..26b8c425198 100644 --- a/.changeset/elk-layout-presets.md +++ b/.changeset/elk-layout-presets.md @@ -21,6 +21,8 @@ config: Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one option and leaves the rest in place, so a preset is a starting point rather than a lock. -**The default placement strategy changes from `NETWORK_SIMPLEX` to `LINEAR_SEGMENTS`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour. +**Node placement changes from `BRANDES_KOEPF` to `LINEAR_SEGMENTS`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour, and is the single switch for it — this is the net change against the last release, measured from what shipped rather than from any intermediate state. + +Subgraphs are a separate case: their contents are placed with `NETWORK_SIMPLEX`, which balances a node against all of its neighbours and so keeps a group's nodes aligned with one another instead of drifting. That is a container setting and is not affected by `preset`. Note that `legacy` uses `GREEDY` cycle breaking rather than the `GREEDY_MODEL_ORDER` the schema previously advertised. That default was declared in the schema but never listed in the shipped defaults, so it reached ELK as undefined and ELK's own default applied — `legacy` reproduces what was rendered, not what was documented. diff --git a/.changeset/elk-non-rect-attachment.md b/.changeset/elk-non-rect-attachment.md index b5bd8819e9d..dc0726adeb1 100644 --- a/.changeset/elk-non-rect-attachment.md +++ b/.changeset/elk-non-rect-attachment.md @@ -7,7 +7,3 @@ fix: edges leave diamonds, stadiums and other non-rectangular shapes without kin ELK routes to ports on a node's bounding box and always leaves one perpendicular to the side it sits on. For a rectangle that port is the attachment point; for anything else the outline is inside the box, so the attachment has to move inwards — and the direction it moves in decides whether the edge stays orthogonal. It used to move along the ray from the node's centre, which lands on the outline at a different offset along the side than the port ELK chose, so the opening segment came out diagonal. The attachment now walks the outline along the edge's own departure axis, staying collinear with ELK's stub: the edge leaves the outline, crosses the box, and carries on in one straight line. Rectangular nodes are unaffected. - -Also in this release: - -- The default `elk.nodePlacementStrategy` is now `NETWORK_SIMPLEX` rather than `BRANDES_KOEPF`. **This changes the layout of existing ELK diagrams**, though most are unaffected: over the ELK edge-case corpus, 9 of 13 diagrams are byte-identical and the rest improve. Set `elk: { nodePlacementStrategy: 'BRANDES_KOEPF' }` to keep the previous placement. diff --git a/.changeset/elk-subgraph-spacing-split.md b/.changeset/elk-subgraph-spacing-split.md index 2b088adf38a..a2596bab76f 100644 --- a/.changeset/elk-subgraph-spacing-split.md +++ b/.changeset/elk-subgraph-spacing-split.md @@ -12,6 +12,6 @@ The two are now bought separately. The base value drops to 24, and the approach `elk.layered.spacing.edgeNodeBetweenLayers` is the key that buys the approach. An earlier attempt used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is edge-to-edge and a different quantity, and a note in the source concluded from it that ELK ignored edge-node spacing "in every key form". It does not; that note was wrong and is corrected. -Subgraph nodes are also placed with `NETWORK_SIMPLEX` and `PORT_POSITION` flexibility, which keeps a group's nodes aligned with one another instead of drifting, and lets a node shift so an edge can leave straight rather than bending off the port. +Subgraph contents also gain `PORT_POSITION` node flexibility, which lets a node shift so an edge can leave straight rather than bending off the port. (Their placement strategy is covered in the `elk.preset` note.) **Existing diagrams with subgraphs will render differently** — groups get tighter and more even. diff --git a/.changeset/intersect-line-half-pixel.md b/.changeset/intersect-line-half-pixel.md index dbe4b8d640a..9da61c0d6f5 100644 --- a/.changeset/intersect-line-half-pixel.md +++ b/.changeset/intersect-line-half-pixel.md @@ -4,8 +4,10 @@ fix: edges attach to non-rectangular shapes on the outline instead of half a pixel off it. -`intersectLine` comes from Graphics Gems, where the coordinates were integers and `denom / 2` was added to the numerator so the integer division rounded instead of truncating. JavaScript division does neither, so the term was never a rounding correction: `(num + denom / 2) / denom` is `num / denom + 0.5`. Every intersection came back displaced half a unit on both axes. +**This is not ELK-specific.** `intersectPolygon` is how every non-rectangular shape finds its edge attachment — diamond, stadium, hexagon, trapezoid, subroutine — in every layout, so a dagre-rendered flowchart with a decision diamond is affected exactly as much as an ELK one. -`intersectPolygon` is how every non-rectangular shape finds its edge attachment — diamond, stadium, hexagon, trapezoid, subroutine — so a vertical ray leaving a node's bottom border returned a point half a pixel to the right of it and half a pixel below it. Enough to give an otherwise orthogonal edge a tiny diagonal opening segment, and, when the ray pointed the other way, to put the attachment just inside the node it was meant to touch. +`intersectLine` comes from Graphics Gems, where the coordinates were integers and the numerator was nudged half a denominator away from zero so the integer division rounded instead of truncating. JavaScript division does neither, so the nudge stopped being a correction and became the whole error: every result came back displaced by `0.5 * sign(num) * sign(denom)`. The magnitude is always exactly half a unit, but the sign is per axis, because the numerator is computed separately for x and y while the denominator is shared — so the two axes could move the same way or opposite ways depending on the geometry, which is why it never looked like a constant offset anyone could spot by eye. -`question.ts` had been subtracting the 0.5 back off for diamonds; that compensation is removed along with the cause. **Rendered output moves by half a pixel wherever a polygon shape terminates an edge.** +That was enough to give an otherwise orthogonal edge a tiny diagonal opening segment, and to put an attachment just inside the node it was meant to touch. + +`question.ts` had been subtracting a flat 0.5 from both axes to compensate for diamonds. That only cancelled the bias when both signs came out positive, and doubled the error to a full unit when they did not — so the compensation goes along with the cause. **Rendered output moves by up to a pixel wherever a polygon shape terminates an edge.** diff --git a/.changeset/line-hop-corner-clearance.md b/.changeset/line-hop-corner-clearance.md index 0db70289520..f43629b4ae0 100644 --- a/.changeset/line-hop-corner-clearance.md +++ b/.changeset/line-hop-corner-clearance.md @@ -8,6 +8,6 @@ A crossing close to a corner used to get a hop squeezed into whatever space was Hops now keep a straight run clear of the bend, and one that would still have to shrink below 60% of the requested radius is dropped instead of drawn. An undrawn hop is an ordinary crossing, which is a much better failure than a broken-looking one. -This shows up wherever a layout stacks edges in narrow lanes: ELK routes subgraph-internal edges 10px apart, and 10px does not hold a 7.07px corner cut plus a 6px hop. +**This changes swimlane diagrams as well as ELK ones.** Swimlanes are the existing consumer of line hops and have them on today, so a swimlane with a crossing near a bend will render differently even for someone not using ELK at all. It shows up wherever a layout stacks edges in narrow lanes: ELK routes subgraph-internal edges 10px apart, and 10px does not hold a 7.07px corner cut plus a 6px hop. A crossing is also ignored now when it lands inside the stretch where either edge is rounding a bend. Crossings are found on polylines, but a rounded edge is not drawn as its polyline — it leaves the line up to 7.07px before each bend and rejoins it that far after. A crossing found inside that stretch is somewhere the stroke never goes, so the hop was arching over blank paper while the two lines carried on touching beside it. diff --git a/packages/mermaid-layout-elk/src/geometry.ts b/packages/mermaid-layout-elk/src/geometry.ts index be0fe4742d9..29175dfedd4 100644 --- a/packages/mermaid-layout-elk/src/geometry.ts +++ b/packages/mermaid-layout-elk/src/geometry.ts @@ -162,8 +162,16 @@ export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P return intersection(bounds, outside, inside); }; -/** Bisection steps used to walk a ray onto the node outline. */ -const OUTLINE_RAY_STEPS = 40; +/** + * Bisection steps used to walk a ray onto the node outline. + * + * Each step halves the bracket and costs one `node.intersect()` call, and both + * endpoints of every edge run this — so the count is paid twice per edge. 20 + * steps take a 200px starting bracket to about 2e-4px, which is four orders of + * magnitude below anything that can be rendered; going further only buys + * precision that the SVG coordinate is rounded away from anyway. + */ +const OUTLINE_RAY_STEPS = 20; /** * Whether a point lies inside the node's outline. diff --git a/packages/mermaid-layout-elk/src/lineHops.ts b/packages/mermaid-layout-elk/src/lineHops.ts index 881296d4b5a..4d7f8bc94c4 100644 --- a/packages/mermaid-layout-elk/src/lineHops.ts +++ b/packages/mermaid-layout-elk/src/lineHops.ts @@ -4,10 +4,28 @@ import { type EdgeGeom, type LayoutData, } from 'mermaid'; - -/** Radius of the arc drawn where one edge hops another. */ +/** + * Radius of the arc drawn where one edge hops another. + * + * Fixed rather than configurable: a hop reads as a hop because every one in the + * diagram is the same size, and `lineJump` already shrinks or drops an + * individual arc where a bend leaves it no room. `elk.lineHops` therefore + * exposes the STYLE (`arc` or `gap`) but not the radius, since a per-diagram + * radius would be a knob whose only good value is this one. + */ const JUMP_RADIUS = 6; +/** + * The paint groups `applyElkLineJumps` needs off the measure context. + * + * The selection type is taken from `applyLineJumpsToSvg`'s own signature rather + * than named here: `D3Selection` is not part of mermaid's public surface, and + * deriving it keeps the two in step without widening that surface. + */ +interface EdgePaintGroups { + groups: { edgePaths: Parameters[0] }; +} + /** * Draw a hop where two edges cross. * @@ -23,7 +41,7 @@ const JUMP_RADIUS = 6; */ export function applyElkLineJumps( data4Layout: LayoutData, - { measure }: CommonLayoutPaintContext + { measure }: CommonLayoutPaintContext ): void { const lineHops = (data4Layout.config as { elk?: { lineHops?: boolean | string } })?.elk?.lineHops; if (lineHops === false) { @@ -31,22 +49,21 @@ export function applyElkLineJumps( } const edgeGeometries: EdgeGeom[] = data4Layout.edges - .filter((edge) => Array.isArray(edge.points) && edge.points.length >= 2) + .filter( + (edge): edge is typeof edge & { points: EdgeGeom['points'] } => + Array.isArray(edge.points) && edge.points.length >= 2 + ) .map((edge) => ({ id: edge.id, - points: edge.points!, + points: edge.points, curve: edge.curve, arrowTypeStart: edge.arrowTypeStart, arrowTypeEnd: edge.arrowTypeEnd, - })) as EdgeGeom[]; + })); - applyLineJumpsToSvg( - (measure as { groups: { edgePaths: never } }).groups.edgePaths, - edgeGeometries, - { - enabled: true, - jumpRadius: JUMP_RADIUS, - jumpStyle: lineHops === 'gap' ? 'gap' : 'arc', - } - ); + applyLineJumpsToSvg(measure.groups.edgePaths, edgeGeometries, { + enabled: true, + jumpRadius: JUMP_RADIUS, + jumpStyle: lineHops === 'gap' ? 'gap' : 'arc', + }); } diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 9480e5c1849..49a2d565f4b 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -750,9 +750,19 @@ const ELK_PRESETS: Record { expect(result).toEqual({ x: 33, y: 7 }); }); + it('is unbiased whichever way the numerator and denominator sign out', () => { + // The removed arithmetic displaced a result by `0.5 * sign(num) * sign(denom)`, + // and `num` is computed per axis while `denom` is shared — so the old code + // could push x one way and y the other. These four cases put the same + // crossing in each sign combination by walking the segments in different + // directions; every one has to land on exactly the same point. + const expected = { x: 33, y: 57 }; + const combos: [Point, Point, Point, Point][] = [ + [ + { x: 33, y: 0 }, + { x: 33, y: 100 }, + { x: 0, y: 57 }, + { x: 100, y: 57 }, + ], + [ + { x: 33, y: 100 }, + { x: 33, y: 0 }, + { x: 0, y: 57 }, + { x: 100, y: 57 }, + ], + [ + { x: 33, y: 0 }, + { x: 33, y: 100 }, + { x: 100, y: 57 }, + { x: 0, y: 57 }, + ], + [ + { x: 33, y: 100 }, + { x: 33, y: 0 }, + { x: 100, y: 57 }, + { x: 0, y: 57 }, + ], + ]; + + for (const [p1, p2, q1, q2] of combos) { + const result = intersectLine(p1, p2, q1, q2); + expect(result, `${JSON.stringify([p1, p2, q1, q2])}`).toBeDefined(); + expect(result!.x).toBeCloseTo(expected.x, 9); + expect(result!.y).toBeCloseTo(expected.y, 9); + } + }); + it('keeps the crossing on the query line for non-integer coordinates', () => { // The case that shows up in real layouts: node centres are fractional, and // the attachment must stay on the vertical ray leaving the node. diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts index 75038e454a1..cbaf4afe4b5 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts @@ -664,11 +664,23 @@ export function applyLineJumpsToSvg( // Collect geometry from each path's data-points, preferring that over the // incoming `edges[].points` which came from pre-render layout state. + // Index the paths by their own `data-id` instead of building one selector per + // edge. An id is author-controlled, so interpolating it into a selector needs + // `CSS.escape`, which is not guaranteed outside a browser — and the fallback + // of using the id raw turns a trailing backslash into a `SyntaxError` that + // aborts the whole render. Reading the attribute avoids the selector entirely. + const pathByDataId = new Map(); + for (const el of groupNode.querySelectorAll('path[data-id]')) { + const id = el.getAttribute('data-id'); + if (id !== null && !pathByDataId.has(id)) { + pathByDataId.set(id, el); + } + } + const renderedEdges: EdgeGeom[] = []; const pathById = new Map(); for (const e of edges) { - const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(e.id) : e.id; - const pathEl = groupNode.querySelector(`path[data-id="${escapedId}"]`); + const pathEl = pathByDataId.get(e.id); if (!pathEl) { continue; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts index 9cd68c28848..10dc0d7cfc9 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts @@ -74,11 +74,12 @@ export async function question(parent: D3Selection // Calculate the intersection point. // - // This used to return `res` shifted by -0.5 on both axes, compensating for - // a half-unit displacement that `intersectLine` applied to every result — - // leftover integer-rounding arithmetic from the Graphics Gems original. The - // displacement is gone, so the compensation has to go with it or the - // diamond's attachment moves half a pixel the other way. + // This used to return `res` shifted by a flat -0.5 on both axes, to + // compensate for leftover integer-rounding arithmetic in `intersectLine`. + // That arithmetic displaced a result by `0.5 * sign(num) * sign(denom)`, + // whose sign varies per axis, so a fixed subtraction only cancelled it when + // both signs came out positive and doubled it to a full unit when they did + // not. The displacement is gone, so the compensation goes with it. return intersect.polygon(bounds, points, point); }; From 68a7015bf63139524bdd0e98762e00b7932ef52c Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 18:42:36 +0200 Subject: [PATCH 14/31] fix(elk): keep a subgraph frame around lanes that belong to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that evenGroupFrames measured a parent purely from its children's boxes, and it was right to be suspicious. Rendering a nested subgraph with a back edge shows the failure: P frame x=110..468 L_b_d_0 escapes at (474,158) (474,75) `b` is inside C and `d` is a sibling of C, so both endpoints are inside P. ELK routes that edge around C, and the lane is genuinely part of P's interior — but P was measured from C's tightened box plus padding, so the frame was pulled in past it and the edge ran outside a group it never leaves. The distinction is which endpoints an edge has, not where its lane sits. An edge leaving a group has a lane that belongs to the layout around it, and the frame should not be drawn round that. An edge with both endpoints inside never leaves, so its lane is interior and the frame keeps it. Nested groups get both readings of the same lane: outside C, inside P. Looking for this turned up a second, quieter bug. `calcOffset` resolves an edge's section against the origin of the container that owns it, and evenGroupFrames was overwriting that origin — so moving a frame sideways would have dragged every edge routed inside it. It never showed because the frames in the test diagram only shrank on the right, leaving posX untouched. ELK's origin is now kept as `elkOrigin` and calcOffset reads that, never the moved frame. Frames are also clamped so this pass can only ever pull one IN. ELK sized the container around everything it put there, so a measurement here wanting more room means this code got something wrong, not that ELK left something out. Nested case now reads P padR=54 — 24 of padding plus the 30px lane it actually contains — with C still at 24/24 and no internal edge outside its frame. The flat six-subgraph diagram is unchanged at 24/24/24. --- .changeset/elk-even-subgraph-frames.md | 2 + .../src/__tests__/render.spec.ts | 102 ++++++++++++++++++ packages/mermaid-layout-elk/src/render.ts | 90 ++++++++++++++-- 3 files changed, 184 insertions(+), 10 deletions(-) diff --git a/.changeset/elk-even-subgraph-frames.md b/.changeset/elk-even-subgraph-frames.md index 4f4668ab2af..27eb481fa34 100644 --- a/.changeset/elk-even-subgraph-frames.md +++ b/.changeset/elk-even-subgraph-frames.md @@ -10,4 +10,6 @@ The lane is real and the edge still needs it, so the space is not reclaimed. Wha The top is left as ELK set it, since it carries the subgraph's title strip and there is no way to tell how much of that padding is the title and how much is spare. +A frame still holds the lanes that genuinely belong to it. An edge with both endpoints inside a group never leaves it, so its lane is part of that group's interior and the frame stays drawn around it — which matters for nested groups, where a lane routed around an inner group sits inside the outer one. + **Subgraphs render tighter, and groups that used to be visibly lopsided are now even.** diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index e20efc75ffb..8153d3c40e1 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -894,6 +894,108 @@ describe('clearContainerAlgorithmOptions', () => { expect(nodeDb.outer.width).toBe(196); // 24 + 148 + 24 }); + it("keeps a frame around a lane belonging to the group's own interior", () => { + // The nested case. An edge from inside C to a sibling of C is routed around + // C: that lane is OUTSIDE C, so C is pulled in past it, but it is INSIDE P + // and P must stay drawn around it. Measuring P from child boxes alone left + // the edge running outside a group it never leaves. + const nodeDb: Record = { + P: { id: 'P', isGroup: true, offset: { posX: 0, posY: 0 }, width: 400, height: 220 }, + C: { id: 'C', isGroup: true, offset: { posX: 24, posY: 48 }, width: 200, height: 148 }, + leaf: { id: 'leaf', offset: { posX: 48, posY: 96 }, width: 100, height: 76 }, + sib: { id: 'sib', offset: { posX: 260, posY: 96 }, width: 60, height: 76 }, + }; + const elk = [ + { + id: 'P', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [ + { + id: 'C', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [{ id: 'leaf' }], + }, + { id: 'sib' }, + ], + }, + ]; + // Routed out of `leaf`, around C at x=350, and back to `sib`. Sections sit + // in P's coordinate space, so `calcOffset` resolves them against P. + const graph = { + edges: [ + { + id: 'e', + sources: ['leaf'], + targets: ['sib'], + sections: [ + { + startPoint: { x: 148, y: 134 }, + bendPoints: [ + { x: 350, y: 134 }, + { x: 350, y: 60 }, + ], + endPoint: { x: 260, y: 134 }, + }, + ], + }, + ], + }; + const layoutState = { + nodeDb, + parentLookupDb: { parentById: { leaf: 'C', C: 'P', sib: 'P' } }, + }; + + evenGroupFrames(elk, layoutState as any, new Map(), graph as any); + + // C ignores the lane — it leaves C — and pulls in to its one child. + expect(nodeDb.C.width).toBe(148); + // P keeps it: the lane reaches x=350, so the frame runs to 350 + 24. + expect(nodeDb.P.offset.posX + nodeDb.P.width).toBe(374); + }); + + it("leaves ELK's own origin readable after moving a frame", () => { + // Edge sections resolve against the container's ORIGINAL origin, so moving + // a frame must not overwrite it or every edge inside would shift with it. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 200, height: 148 }, + n: { id: 'n', offset: { posX: 40, posY: 48 }, width: 100, height: 76 }, + }; + + evenGroupFrames( + [{ id: 'g', isGroup: true, labelData: { width: 0 }, labels: [], children: [{ id: 'n' }] }], + { nodeDb } as any, + new Map() + ); + + expect(nodeDb.g.offset.posX).toBe(16); // frame moved in to 40 - 24 + expect(nodeDb.g.elkOrigin).toEqual({ posX: 0, posY: 0 }); + }); + + it('never grows a frame beyond what ELK sized it to', () => { + // ELK sized the container around everything it put inside, so a measurement + // here that wants MORE room means this pass got something wrong. Clamp + // rather than trust it. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 120, height: 148 }, + n: { id: 'n', offset: { posX: 10, posY: 48 }, width: 100, height: 76 }, + }; + + evenGroupFrames( + [{ id: 'g', isGroup: true, labelData: { width: 0 }, labels: [], children: [{ id: 'n' }] }], + { nodeDb } as any, + new Map() + ); + + // 10 - 24 would put the left edge at -14 and 110 + 24 the right at 134; + // both are clamped back to the frame ELK gave. + expect(nodeDb.g.offset.posX).toBe(0); + expect(nodeDb.g.width).toBe(120); + }); + it('skips a group with no children rather than collapsing it', () => { const nodeDb: Record = { g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 200, height: 100 }, diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 49a2d565f4b..cb831344ada 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -45,6 +45,11 @@ interface NodeWithVertex { height?: number; intersect?: (point: P) => P | null; isGroup?: boolean; + /** + * Where ELK put this container, kept when `evenGroupFrames` moves the drawn + * frame. Edge sections resolve against this, never against the moved frame. + */ + elkOrigin?: { posX: number; posY: number }; padding?: number; parentId?: string; shape?: string; @@ -1110,7 +1115,7 @@ function applyElkLayoutResult( // Between positions and edges on purpose: `boundsFor` reads the box set // above, and `cutter2` clips an edge that ends on a group against it, so an // edge attaching to a frame follows the frame when it moves. - evenGroupFrames(graph.children ?? [], layoutState, nodeById); + evenGroupFrames(graph.children ?? [], layoutState, nodeById, graph); applyElkEdgeLayout(data4Layout, graph, layoutState, log); } @@ -1137,17 +1142,60 @@ function applyElkLayoutResult( * Runs deepest-first, so a parent measures against children that have already * been pulled in rather than against their original boxes. */ +export function collectDescendantIds(elkNode: any, into = new Set()): Set { + for (const child of elkNode.children ?? []) { + into.add(child.id); + collectDescendantIds(child, into); + } + return into; +} + +/** + * Absolute points of every edge ELK routed INSIDE this group — meaning both of + * its endpoints are descendants of the group. + * + * An edge with one endpoint outside is the case this whole pass exists for: its + * lane belongs to the layout around the group, not to the group, so the frame + * should not be drawn around it. An edge with both endpoints inside is the + * opposite — its lane is part of the group's interior, and a frame pulled in + * past it would leave the edge running outside a group it never leaves. + */ +function internalEdgePoints( + graph: ElkLayoutResult, + descendants: Set, + layoutState: ElkLayoutState +): P[] { + const points: P[] = []; + for (const edge of graph.edges ?? []) { + const source = edge.sources?.[0] ?? edge.start; + const target = edge.targets?.[0] ?? edge.end; + if (!descendants.has(source) || !descendants.has(target)) { + continue; + } + const offset = calcOffset(source, target, layoutState.parentLookupDb, layoutState.nodeDb); + for (const section of edge.sections ?? []) { + for (const p of [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]) { + if (p) { + points.push({ x: p.x + offset.x, y: p.y + offset.y }); + } + } + } + } + return points; +} + export function evenGroupFrames( elkNodes: any[], layoutState: ElkLayoutState, - nodeById: Map + nodeById: Map, + graph: ElkLayoutResult = {} ): void { for (const elkNode of elkNodes) { if (!elkNode?.isGroup) { continue; } const children = elkNode.children ?? []; - evenGroupFrames(children, layoutState, nodeById); + evenGroupFrames(children, layoutState, nodeById, graph); const group = layoutState.nodeDb[elkNode.id]; const boxes = children @@ -1157,12 +1205,26 @@ export function evenGroupFrames( continue; } - const left = Math.min(...boxes.map((b: NodeWithVertex) => b.offset!.posX)) - SUBGRAPH_PADDING; - const right = - Math.max(...boxes.map((b: NodeWithVertex) => b.offset!.posX + b.width!)) + SUBGRAPH_PADDING; - const bottom = - Math.max(...boxes.map((b: NodeWithVertex) => b.offset!.posY + b.height!)) + SUBGRAPH_PADDING; - const top = group.offset.posY; + const lane = internalEdgePoints(graph, collectDescendantIds(elkNode), layoutState); + const xs = [ + ...boxes.map((b: NodeWithVertex) => b.offset!.posX), + ...boxes.map((b: NodeWithVertex) => b.offset!.posX + b.width!), + ...lane.map((p) => p.x), + ]; + const ys = [ + ...boxes.map((b: NodeWithVertex) => b.offset!.posY), + ...boxes.map((b: NodeWithVertex) => b.offset!.posY + b.height!), + ...lane.map((p) => p.y), + ]; + + // Only ever pull a frame IN. ELK sized it to hold everything it put there, + // so growing one would mean this pass had measured something ELK had not — + // more likely a bug here than a gap there. + const origin = group.offset; + const left = Math.max(origin.posX, Math.min(...xs) - SUBGRAPH_PADDING); + const right = Math.min(origin.posX + group.width!, Math.max(...xs) + SUBGRAPH_PADDING); + const bottom = Math.min(origin.posY + group.height!, Math.max(...ys) + SUBGRAPH_PADDING); + const top = origin.posY; // A frame narrower than its own title would cut the title off. Both the // drawn rect and `getEffectiveGroupWidth` have their own idea of the floor, @@ -1182,6 +1244,10 @@ export function evenGroupFrames( continue; } + // `calcOffset` resolves an edge's section against the origin of the + // container that owns it, so moving a frame would drag every edge routed + // inside it. Keep what ELK chose and let `calcOffset` read that instead. + group.elkOrigin ??= { posX: origin.posX, posY: origin.posY }; group.offset.posX = x; group.offset.width = width; group.offset.height = height; @@ -1600,7 +1666,11 @@ function calcOffset( return { x: 0, y: 0 }; } - const ancestorOffset = nodeDb[ancestor]?.offset; + // `elkOrigin` when present: `evenGroupFrames` may have moved the frame, but a + // section's coordinates are relative to where ELK put the container, not to + // where the frame is now drawn. + const node = nodeDb[ancestor]; + const ancestorOffset = node?.elkOrigin ?? node?.offset; return { x: ancestorOffset?.posX ?? 0, y: ancestorOffset?.posY ?? 0, From 5099cbe725f5ba3e35a4882eae0ba6621243f72e Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 18:51:22 +0200 Subject: [PATCH 15/31] fix(elk): put the edge-approach spacing back to 30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the DDLT corpus against this branch — which I should have done before marking the PR ready, not after — turned up a regression the browser check could not see. At edgeNodeBetweenLayers: 20, right-angles-not-curves starts tripping edge-parallel-segment-too-close and the corpus goes to two invalid fixtures instead of one. The cause is that this spacing does a third job I had not accounted for: as well as the approach run and the routing lane, it separates edges running alongside each other in the layer gap. Over the corpus it is not monotonic, so "as low as it goes" was the wrong way to pick it: x=20 invalid 2/14 aggregate 9991.8 x=25 invalid 3/14 aggregate 9990.7 x=30 invalid 1/14 aggregate 9989.6 x=40 invalid 1/14 aggregate 9987.7 30 is the lowest value that leaves only the deliberate merge-edge counterexample invalid. Going back up costs nothing that matters now. The reason to be low was the lane this value also pays for, which used to show as lopsided padding — but evenGroupFrames pulls the frame in past that lane regardless, so the only remaining cost is overall diagram size. The approach run is unchanged either way: 40 measured the same 30px shortest approach as 30 does, so it stops buying anything there. Six-subgraph diagram still reads 24/24/24 on every group with a 30px shortest approach, and the nested case still keeps its interior lane inside the outer frame. --- .changeset/elk-subgraph-spacing-split.md | 2 +- packages/mermaid-layout-elk/src/render.ts | 28 ++++++++++++----------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.changeset/elk-subgraph-spacing-split.md b/.changeset/elk-subgraph-spacing-split.md index a2596bab76f..3e03430ff4e 100644 --- a/.changeset/elk-subgraph-spacing-split.md +++ b/.changeset/elk-subgraph-spacing-split.md @@ -8,7 +8,7 @@ A subgraph could end up with far more space on one side than the other for no re That base value was doing two jobs at once. Every unset ELK spacing derives from it, so it had to stay large enough that an edge got a straight run before the node it enters — below about 40 the approach came out shorter than the arrowhead and the turn read as happening underneath it. But an edge routed down the inside of a frame claims a lane the same width, so paying for the approach out of the base value also pushed groups clear of their own borders. -The two are now bought separately. The base value drops to 24, and the approach run, node separation and edge separation are set explicitly. Subgraph padding is even again, and edges keep the run they had. +The two are now bought separately. The base value drops to 24, and the approach run, node separation and edge separation are set explicitly, so edges keep the run they had without the frame paying for it. `elk.layered.spacing.edgeNodeBetweenLayers` is the key that buys the approach. An earlier attempt used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is edge-to-edge and a different quantity, and a note in the source concluded from it that ELK ignored edge-node spacing "in every key form". It does not; that note was wrong and is corrected. diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index cb831344ada..185b61fdb3e 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -313,20 +313,22 @@ export function buildSubgraphLayoutOptions( // the layered-scoped key; the unscoped `spacing.edgeNodeBetweenLayers` is // not an ELK id at all and setting it does nothing. // - // 20 is a balance, not a free choice. This value is charged TWICE against a - // group that has an edge routed down the inside of its frame — once between - // the nodes and the edge's lane, and again between that lane and the frame. - // Measured across four values on a six-subgraph diagram, that group's extra - // width came out at exactly `36 + 2x`, so 40 cost 40px of frame more than - // 20 does. It also stops buying anything above 30. + // 30, which is where the approach run stops improving: 40 measured the same + // 30px shortest approach and only widened the lane this value also pays + // for. That lane used to be the reason to go lower — the value is charged + // TWICE against a group with an edge routed inside its frame, once between + // the nodes and the lane and again between the lane and the frame, so the + // group's extra width came out at exactly `36 + 2x`. `evenGroupFrames` now + // pulls the frame in past the lane regardless, so a wider lane no longer + // shows as lopsided padding and the only cost left is overall diagram size. // - // Nothing separates the two uses. `elk.spacing.edgeEdge` and - // `elk.spacing.edgeNode` both leave the lane and the frame unchanged, and - // buying the same approach out of `spacing.baseValue` instead costs MORE - // frame, not less (approach 20 costs 84px that way against 76px here). So - // this key is the cheapest way to buy the approach, and 20 is as low as it - // goes while keeping every edge clear of its own arrowhead. - 'elk.layered.spacing.edgeNodeBetweenLayers': 20, + // Do NOT lower it further on that reasoning. Over the DDLT corpus this is + // not monotonic: 30 and 40 leave one fixture invalid (the deliberate + // merge-edge counterexample), while 20 leaves two and 25 leaves three — + // `right-angles-not-curves` starts tripping `edge-parallel-segment-too-close` + // because this spacing also separates edges running alongside each other in + // the layer gap. 30 is the lowest value that keeps the corpus clean. + 'elk.layered.spacing.edgeNodeBetweenLayers': 30, // Separation between edges sharing a lane. Also raised off the base value, // so that lowering the base does not leave parallel edges touching. 'elk.spacing.edgeEdge': 20, From aa72e1c35135ca2710ba26211e2494b295afb1e5 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 19:56:01 +0200 Subject: [PATCH 16/31] fix(elk): address CodeRabbit review on #8152 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five findings were real. Verified each against the code first; the corpus is unchanged at 9989.6 with one invalid fixture (the deliberate merge-edge counterexample) after all of them. Major — two aliases for one ELK option. buildSubgraphLayoutOptions set both `nodePlacement.strategy` (honouring the preset and an explicit `nodePlacementStrategy`) and `elk.layered.nodePlacement.strategy` (hardcoded NETWORK_SIMPLEX). ELK reads those as the same option, so the container held two values for it with no say in which won, and an explicit `elk.nodePlacementStrategy` was quietly ignored for subgraph contents. This is the duplicate-key trap elkOptionCatalogue.ts already documents, and I walked into it. Now one fully-qualified key, and the container's default comes from the preset rather than a literal: presets gained `containerPlacement`, so `legacy` puts subgraph contents back on BRANDES_KOEPF too. Without that `legacy` would have been a half-restore that still laid groups out the new way. The root key is qualified as well — ELK's own docs warn that an unqualified suffix can collide. Minor — the label-width floor bypassed the "only ever pull a frame IN" clamp, and the test I wrote encoded that as intended. A title wider than the frame ELK sized means ELK did not reserve for its own title; widening the frame here papers over that while breaking the one guarantee the pass makes. Clamped, and the test now says why. Minor — MIN_USEFUL_RADIUS_RATIO was applied before the adjacency pass but not after, so a hop shrunk to keep clear of its neighbour could still be drawn below the bar. At the shipped radius of 6, two crossings 4px apart would each get 2px, which does not clear the stroke being hopped — the same defect the rule exists to prevent. Checked after the clamp as well. The existing adjacency test moved to a 1.6 gap so it still covers the non-inversion case it was written for, with a new test for the too-close case. Minor — outlineAttachPoint's comment claimed a diagonal departure was declined, but the code forced it onto the dominant axis, which attaches at a point the edge does not pass through and reintroduces the offset the function exists to remove. It declines now, as documented, with tests for 45 degrees and both dominant-axis diagonals, plus one proving floating-point dust still counts as axis-aligned. Minor — the straightenEdges schema text still described the original behaviour ("moves the endpoint onto the channel row"). The rewrite moves the channel onto the port's row precisely so ports do NOT move. Corrected at the source and regenerated, which fixes config.type.ts and the docs with it. MIN_JUMP_RADIUS is gone, its degeneracy guard now covered by the useful radius bar. --- .../mermaid/functions/applyLineJumpsToSvg.md | 2 +- .../setup/mermaid/interfaces/MermaidConfig.md | 105 +++++++++--------- .../src/__tests__/geometry.spec.ts | 24 ++++ .../src/__tests__/render.spec.ts | 51 ++++++--- packages/mermaid-layout-elk/src/geometry.ts | 19 +++- packages/mermaid-layout-elk/src/render.ts | 94 ++++++++++------ packages/mermaid/src/config.type.ts | 7 +- .../rendering-elements/lineJump.spec.ts | 56 ++++++++-- .../rendering-elements/lineJump.ts | 8 +- .../mermaid/src/schemas/config.schema.yaml | 7 +- 10 files changed, 249 insertions(+), 124 deletions(-) diff --git a/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md b/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md index 55b348edde2..18e5320b618 100644 --- a/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md +++ b/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md @@ -12,7 +12,7 @@ > **applyLineJumpsToSvg**(`edgePathsGroup`, `edges`, `config`): `void` -Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:644](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L644) +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:646](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L646) Patches the rendered SVG paths in `edgePathsGroup` for any edges that cross. The true geometry is read from each path's `data-points` attribute diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index bd6f807341e..f86ed6af34d 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/config.type.ts:66](https://github.com/mermaid- > `optional` **agentflow**: `AgentflowDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:313](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L313) +Defined in: [packages/mermaid/src/config.type.ts:316](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L316) --- @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/config.type.ts:313](https://github.com/mermaid > `optional` **altFontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:251](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L251) +Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L254) --- @@ -34,7 +34,7 @@ Defined in: [packages/mermaid/src/config.type.ts:251](https://github.com/mermaid > `optional` **architecture**: `ArchitectureDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325) +Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L328) --- @@ -42,7 +42,7 @@ Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid > `optional` **arrowMarkerAbsolute**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:270](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L270) +Defined in: [packages/mermaid/src/config.type.ts:273](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L273) Controls whether or arrow markers in html code are absolute paths or anchors. This matters if you are using base tag settings. @@ -53,7 +53,7 @@ This matters if you are using base tag settings. > `optional` **block**: `BlockDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333) +Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336) --- @@ -61,7 +61,7 @@ Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid > `optional` **c4**: `C4DiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L330) +Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333) --- @@ -69,7 +69,7 @@ Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid > `optional` **class**: `ClassDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:318](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L318) +Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L321) --- @@ -77,7 +77,7 @@ Defined in: [packages/mermaid/src/config.type.ts:318](https://github.com/mermaid > `optional` **cynefin**: `CynefinDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340) +Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343) --- @@ -85,7 +85,7 @@ Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid > `optional` **darkMode**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:235](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L235) +Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L238) --- @@ -93,7 +93,7 @@ Defined in: [packages/mermaid/src/config.type.ts:235](https://github.com/mermaid > `optional` **deterministicIds**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:303](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L303) +Defined in: [packages/mermaid/src/config.type.ts:306](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L306) This option controls if the generated ids of nodes in the SVG are generated randomly or based on a seed. @@ -109,7 +109,7 @@ should not change unless content is changed. > `optional` **deterministicIDSeed**: `string` -Defined in: [packages/mermaid/src/config.type.ts:310](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L310) +Defined in: [packages/mermaid/src/config.type.ts:313](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L313) This option is the optional seed for deterministic ids. If set to `undefined` but deterministicIds is `true`, a simple number iterator is used. @@ -121,7 +121,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342) +Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345) --- @@ -247,11 +247,14 @@ ELK spreads an edge's port evenly along a node's side but routes the edge down a channel whose row rarely lines up with that port exactly, leaving a staircase of a few pixels right at the border. With rounded corners the two micro-bends land on top of each other and read as a -kink. Enabling this moves the endpoint onto the channel row — still on -the node's border — and drops the step. +kink. Enabling this moves the channel onto the port's row and drops +the step, so the edge draws as one straight line and both ports stay +exactly where the layout put them. Only the step next to a node is touched, and only when the edge continues the same way afterwards, so a real turn is never collapsed. +An edge is left alone entirely when moving its run would drag the far +port, or would introduce a crossing. --- @@ -259,7 +262,7 @@ continues the same way afterwards, so a real turn is never collapsed. > `optional` **er**: `ErDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L320) +Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L323) --- @@ -267,7 +270,7 @@ Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid > `optional` **eventmodeling**: `EventModelingDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334) +Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337) --- @@ -275,7 +278,7 @@ Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid > `optional` **flowchart**: `FlowchartDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:311](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L311) +Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L314) --- @@ -283,7 +286,7 @@ Defined in: [packages/mermaid/src/config.type.ts:311](https://github.com/mermaid > `optional` **fontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:250](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L250) +Defined in: [packages/mermaid/src/config.type.ts:253](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L253) Specifies the font to be used in the rendered diagrams. Can be any possible CSS `font-family`. @@ -295,7 +298,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344) +Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L347) --- @@ -303,7 +306,7 @@ Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid > `optional` **forceLegacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:292](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L292) +Defined in: [packages/mermaid/src/config.type.ts:295](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L295) This option forces Mermaid to rely on KaTeX's own stylesheet for rendering MathML. Due to differences between OS fonts and browser's MathML implementation, this option is recommended if consistent rendering is important. @@ -315,7 +318,7 @@ If set to true, ignores legacyMathML. > `optional` **gantt**: `GanttDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:315](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L315) +Defined in: [packages/mermaid/src/config.type.ts:318](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L318) --- @@ -323,7 +326,7 @@ Defined in: [packages/mermaid/src/config.type.ts:315](https://github.com/mermaid > `optional` **gitGraph**: `GitGraphDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329) +Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332) --- @@ -341,7 +344,7 @@ Defines the seed to be used when using handDrawn look. This is important for the > `optional` **htmlLabels**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:243](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L243) +Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) Flag for setting whether or not a html tag should be used for rendering labels on nodes and edges. **Note:** Diagram-specific `htmlLabels` settings (e.g., `flowchart.htmlLabels`) are deprecated. @@ -354,7 +357,7 @@ over any diagram-specific settings. > `optional` **ishikawa**: `IshikawaDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L327) +Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L330) --- @@ -362,7 +365,7 @@ Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid > `optional` **journey**: `JourneyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:316](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L316) +Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L319) --- @@ -370,7 +373,7 @@ Defined in: [packages/mermaid/src/config.type.ts:316](https://github.com/mermaid > `optional` **kanban**: `KanbanDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L328) +Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L331) --- @@ -388,7 +391,7 @@ Defines which layout algorithm to use for rendering the diagram. > `optional` **legacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:285](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L285) +Defined in: [packages/mermaid/src/config.type.ts:288](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L288) This option specifies if Mermaid can expect the dependent to include KaTeX stylesheets for browsers without their own MathML implementation. If this option is disabled and MathML is not supported, the math @@ -401,7 +404,7 @@ fall back to legacy rendering for KaTeX. > `optional` **logLevel**: `0` | `2` | `1` | `"trace"` | `"debug"` | `"info"` | `"warn"` | `"error"` | `"fatal"` | `3` | `4` | `5` -Defined in: [packages/mermaid/src/config.type.ts:256](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L256) +Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L259) This option decides the amount of logging to be used by mermaid. @@ -421,7 +424,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345) +Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L348) --- @@ -449,7 +452,7 @@ The maximum allowed size of the users text diagram > `optional` **mindmap**: `MindmapDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L326) +Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329) --- @@ -457,7 +460,7 @@ Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid > `optional` **packet**: `PacketDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332) +Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335) --- @@ -465,7 +468,7 @@ Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid > `optional` **pie**: `PieDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L321) +Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L324) --- @@ -473,7 +476,7 @@ Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid > `optional` **quadrantChart**: `QuadrantChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322) +Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325) --- @@ -481,7 +484,7 @@ Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid > `optional` **radar**: `RadarDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336) +Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339) --- @@ -489,7 +492,7 @@ Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid > `optional` **railroad**: `RailroadDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341) +Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344) --- @@ -497,7 +500,7 @@ Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid > `optional` **requirement**: `RequirementDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L324) +Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L327) --- @@ -505,7 +508,7 @@ Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid > `optional` **sankey**: `SankeyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L331) +Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334) --- @@ -513,7 +516,7 @@ Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid > `optional` **secure**: `string`\[] -Defined in: [packages/mermaid/src/config.type.ts:277](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L277) +Defined in: [packages/mermaid/src/config.type.ts:280](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L280) This option controls which `currentConfig` keys are considered secure and can only be changed via call to `mermaid.initialize`. @@ -525,7 +528,7 @@ This prevents malicious graph directives from overriding a site's default securi > `optional` **securityLevel**: `"strict"` | `"loose"` | `"antiscript"` | `"sandbox"` -Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L260) +Defined in: [packages/mermaid/src/config.type.ts:263](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L263) Level of trust for parsed diagram @@ -535,7 +538,7 @@ Level of trust for parsed diagram > `optional` **sequence**: `SequenceDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L314) +Defined in: [packages/mermaid/src/config.type.ts:317](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L317) --- @@ -543,7 +546,7 @@ Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid > `optional` **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:264](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L264) +Defined in: [packages/mermaid/src/config.type.ts:267](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L267) Dictates whether mermaid starts on Page load @@ -553,7 +556,7 @@ Dictates whether mermaid starts on Page load > `optional` **state**: `StateDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L319) +Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322) --- @@ -561,7 +564,7 @@ Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L351) +Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L354) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -572,7 +575,7 @@ This is useful when you want to control how to handle syntax errors in your appl > `optional` **swimlane**: `SwimlaneDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:312](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L312) +Defined in: [packages/mermaid/src/config.type.ts:315](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L315) --- @@ -607,7 +610,7 @@ Defined in: [packages/mermaid/src/config.type.ts:85](https://github.com/mermaid- > `optional` **timeline**: `TimelineDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:317](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L317) +Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L320) --- @@ -615,7 +618,7 @@ Defined in: [packages/mermaid/src/config.type.ts:317](https://github.com/mermaid > `optional` **treeView**: `TreeViewDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335) +Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338) --- @@ -623,7 +626,7 @@ Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid > `optional` **usecase**: `UsecaseDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337) +Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340) --- @@ -631,7 +634,7 @@ Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid > `optional` **venn**: `VennDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338) +Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341) --- @@ -639,7 +642,7 @@ Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid > `optional` **wardley-beta**: `WardleyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339) +Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342) --- @@ -647,7 +650,7 @@ Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343) +Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L346) --- @@ -655,4 +658,4 @@ Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid > `optional` **xyChart**: `XYChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L323) +Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L326) diff --git a/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts b/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts index d8776397b1f..6a2a9f5f8e1 100644 --- a/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts @@ -136,6 +136,30 @@ describe('geometry helpers', () => { outlineAttachPoint({}, bounds, { x: 129.48, y: 454.87 }, { x: 144.48, y: 454.87 }) ).toBe(null); }); + + it('declines a diagonal departure rather than forcing it onto an axis', () => { + // The bisection walks one axis holding the other fixed, so a diagonal has + // no axis to preserve. Forcing it onto the dominant one would attach at a + // point the edge does not pass through — reintroducing exactly the offset + // this function exists to remove — so the caller's centre-ray path is the + // honest fallback. + const port = { x: 129.48, y: 454.87 }; + + expect(outlineAttachPoint(diamond, bounds, port, { x: 144.48, y: 469.87 })).toBe(null); + // Dominant-x and dominant-y diagonals are both declined, not just the 45°. + expect(outlineAttachPoint(diamond, bounds, port, { x: 174.48, y: 459.87 })).toBe(null); + expect(outlineAttachPoint(diamond, bounds, port, { x: 134.48, y: 494.87 })).toBe(null); + }); + + it('still accepts a departure carrying floating-point dust', () => { + // ELK emits exact orthogonal stubs, but the numbers reaching here have been + // through arithmetic. A sub-nanometre minor component is not a diagonal. + const port = { x: 129.48, y: 454.87 }; + + expect(outlineAttachPoint(diamond, bounds, port, { x: 144.48, y: 454.87 + 1e-9 })).not.toBe( + null + ); + }); }); describe('straightenTerminalJogs', () => { diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index 8153d3c40e1..4a415ec3520 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -114,7 +114,7 @@ describe('buildSubgraphLayoutOptions', () => { { nodePlacementStrategy: 'BRANDES_KOEPF' }, 'layered' ); - expect(opts['nodePlacement.strategy']).toBe('BRANDES_KOEPF'); + expect(opts['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); }); it('defaults nodePlacementAlignment to NONE', () => { @@ -131,8 +131,10 @@ describe('buildSubgraphLayoutOptions', () => { const opts = buildSubgraphLayoutOptions({}, undefined, 'layered'); expect(opts['elk.layered.mergeEdges']).toBeUndefined(); // With no config at all the `default` preset supplies the placement - // strategy, so this is no longer undefined. - expect(opts['nodePlacement.strategy']).toBe('LINEAR_SEGMENTS'); + // strategy. Containers get NETWORK_SIMPLEX where the root gets + // LINEAR_SEGMENTS: balancing a node against all of its neighbours is what + // keeps a group's nodes aligned with each other rather than drifting. + expect(opts['elk.layered.nodePlacement.strategy']).toBe('NETWORK_SIMPLEX'); expect(opts['elk.layered.nodePlacement.bk.fixedAlignment']).toBe('NONE'); }); @@ -144,16 +146,30 @@ describe('buildSubgraphLayoutOptions', () => { { preset: 'legacy', nodePlacementStrategy: 'SIMPLE' }, 'layered' ); - expect(opts['nodePlacement.strategy']).toBe('SIMPLE'); + expect(opts['elk.layered.nodePlacement.strategy']).toBe('SIMPLE'); }); - it('takes the placement strategy from the named preset', () => { - expect( - buildSubgraphLayoutOptions({}, { preset: 'legacy' }, 'layered')['nodePlacement.strategy'] - ).toBe('BRANDES_KOEPF'); - expect( - buildSubgraphLayoutOptions({}, { preset: 'depthFirst' }, 'layered')['nodePlacement.strategy'] - ).toBe('LINEAR_SEGMENTS'); + it('takes the container placement strategy from the named preset', () => { + const placement = (preset: string) => + buildSubgraphLayoutOptions({}, { preset }, 'layered')['elk.layered.nodePlacement.strategy']; + + // `legacy` exists to reproduce what earlier versions rendered, so it has to + // reach containers too — leaving them on the new strategy would make it a + // half-restore that still lays subgraph contents out differently. + expect(placement('legacy')).toBe('BRANDES_KOEPF'); + expect(placement('depthFirst')).toBe('NETWORK_SIMPLEX'); + expect(placement('default')).toBe('NETWORK_SIMPLEX'); + }); + + it('names the placement option once, fully qualified', () => { + // ELK reads `nodePlacement.strategy` and `elk.layered.nodePlacement.strategy` + // as the same option. Setting both left the container holding two values + // for it with no say in which won, which silently ignored an explicit + // `nodePlacementStrategy`. + const opts = buildSubgraphLayoutOptions({}, { nodePlacementStrategy: 'SIMPLE' }, 'layered'); + + expect(opts).not.toHaveProperty('nodePlacement.strategy'); + expect(opts['elk.layered.nodePlacement.strategy']).toBe('SIMPLE'); }); it('applies a per-group algorithm from metadata with SEPARATE_CHILDREN', () => { @@ -777,7 +793,7 @@ describe('clearContainerAlgorithmOptions', () => { clearContainerAlgorithmOptions(options); expect(options['elk.layered.mergeEdges']).toBe(true); - expect(options['nodePlacement.strategy']).toBe('BRANDES_KOEPF'); + expect(options['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); expect(options['nodeLabels.placement']).toBe('[H_CENTER V_TOP, INSIDE]'); }); @@ -835,8 +851,8 @@ describe('clearContainerAlgorithmOptions', () => { }); it('never squeezes a frame narrower than its own title', () => { - // A one-node group under a long title. Pulling in to the node would cut the - // title off, so the floor wins and the frame stays centred on its contents. + // A one-node group under a long title. Pulling in to the node would cut + // the title off, so the floor wins it back. const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 300, h: 148 }, [ [24, 48, 40, 76], ]); @@ -845,8 +861,11 @@ describe('clearContainerAlgorithmOptions', () => { evenGroupFrames(elk, layoutState, new Map()); expect(nodeDb.g.width).toBe(200); - // Centred on the node's midpoint at x=44, so 44 - 100. - expect(nodeDb.g.offset.posX).toBe(-56); + // Centring on the node's midpoint at x=44 would want to start at -56, but + // the frame is clamped to what ELK gave. Pulling a frame IN is the only + // thing this pass may do — a title too wide for its own frame is ELK's to + // size, and widening it here would paper over that. + expect(nodeDb.g.offset.posX).toBe(0); }); it('measures a parent against children it has already pulled in', () => { diff --git a/packages/mermaid-layout-elk/src/geometry.ts b/packages/mermaid-layout-elk/src/geometry.ts index 29175dfedd4..b8f2afb6537 100644 --- a/packages/mermaid-layout-elk/src/geometry.ts +++ b/packages/mermaid-layout-elk/src/geometry.ts @@ -173,6 +173,14 @@ export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P */ const OUTLINE_RAY_STEPS = 20; +/** + * How far off an axis a departure may sit and still count as axis-aligned. + * + * ELK's orthogonal routing emits exact horizontal and vertical stubs, so this + * only has to absorb floating-point dust, not a tolerance for near-diagonals. + */ +const DEPARTURE_AXIS_EPS = 1e-6; + /** * Whether a point lies inside the node's outline. * @@ -229,9 +237,16 @@ export const outlineAttachPoint = ( return null; } + // A diagonal departure has no single axis to preserve. The bisection below + // walks along one axis holding the other fixed, so forcing a diagonal onto + // its dominant axis would attach at a point the edge does not actually pass + // through — reintroducing the offset this function exists to remove. Decline + // instead, and let the caller fall back to the centre-ray intersection. + if (Math.abs(dx) > DEPARTURE_AXIS_EPS && Math.abs(dy) > DEPARTURE_AXIS_EPS) { + return null; + } + const centre = { x: bounds.x, y: bounds.y }; - // The departure axis. A diagonal departure has no single axis to preserve, so - // there is nothing here to improve on. const horizontal = Math.abs(dx) > Math.abs(dy); const along = (t: number): P => (horizontal ? { x: t, y: port.y } : { x: port.x, y: t }); diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 185b61fdb3e..96970a5732d 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -340,16 +340,24 @@ export function buildSubgraphLayoutOptions( // given here, so adding the label height again double-counts it. 'elk.padding': `[top=${SUBGRAPH_PADDING},left=${SUBGRAPH_PADDING},bottom=${SUBGRAPH_PADDING},right=${SUBGRAPH_PADDING}]`, 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', - 'nodePlacement.strategy': - elkConfig?.nodePlacementStrategy ?? resolveElkPreset(elkConfig?.preset).placement, + 'elk.layered.mergeEdges': elkConfig?.mergeEdges, 'elk.layered.nodePlacement.bk.fixedAlignment': elkConfig?.nodePlacementAlignment ?? DEFAULT_NODE_PLACEMENT_ALIGNMENT, - // Containers place their own children. NETWORK_SIMPLEX balances a node - // against all of its neighbours, which keeps a group's nodes aligned with - // each other instead of drifting; PORT_POSITION lets it shift a node so an - // edge can leave straight rather than bending immediately off the port. - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + // Containers place their own children, and the preset says how: by default + // NETWORK_SIMPLEX, which balances a node against all of its neighbours and + // so keeps a group's nodes aligned with each other instead of drifting, + // while the root uses LINEAR_SEGMENTS. `legacy` keeps both on the strategy + // that shipped before, so it still reproduces the old rendering. + // + // ONE key, fully qualified. ELK reads `nodePlacement.strategy` and + // `elk.layered.nodePlacement.strategy` as the same option, so listing both + // — as this did — leaves the container holding two values for it with no + // say in which wins, and quietly ignores an explicit `nodePlacementStrategy`. + 'elk.layered.nodePlacement.strategy': + elkConfig?.nodePlacementStrategy ?? resolveElkPreset(elkConfig?.preset).containerPlacement, + // PORT_POSITION lets a node shift so an edge can leave straight rather than + // bending immediately off the port. 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'PORT_POSITION', }; @@ -727,35 +735,40 @@ function getElkLayoutContext( * `cycleBreakingStrategy` beats the preset for that one option — which is why * `defaultConfig` leaves all three undefined rather than giving them values. */ -const ELK_PRESETS: Record = - { - /** Keeps chains of nodes aligned. */ - default: { - layering: 'NETWORK_SIMPLEX', - placement: 'LINEAR_SEGMENTS', - cycleBreaking: 'GREEDY_MODEL_ORDER', - }, - /** - * What shipped before presets: straighter long edges, less alignment. - * - * `GREEDY`, not `GREEDY_MODEL_ORDER`, is deliberate. The schema advertised - * the latter, but `defaultConfig` never listed `cycleBreakingStrategy`, so it - * reached ELK as undefined and ELK's own default applied. This preset - * reproduces what `develop` actually renders, not what its schema claimed. - * Layering is ELK's default too — `develop` does not wire the option at all. - */ - legacy: { - layering: 'NETWORK_SIMPLEX', - placement: 'BRANDES_KOEPF', - cycleBreaking: 'GREEDY', - }, - /** As `default`, but shorter back edges on graphs that have many. */ - depthFirst: { - layering: 'NETWORK_SIMPLEX', - placement: 'LINEAR_SEGMENTS', - cycleBreaking: 'DEPTH_FIRST', - }, - }; +const ELK_PRESETS: Record< + string, + { layering: string; placement: string; containerPlacement: string; cycleBreaking: string } +> = { + /** Keeps chains of nodes aligned. */ + default: { + layering: 'NETWORK_SIMPLEX', + placement: 'LINEAR_SEGMENTS', + containerPlacement: 'NETWORK_SIMPLEX', + cycleBreaking: 'GREEDY_MODEL_ORDER', + }, + /** + * What shipped before presets: straighter long edges, less alignment. + * + * `GREEDY`, not `GREEDY_MODEL_ORDER`, is deliberate. The schema advertised + * the latter, but `defaultConfig` never listed `cycleBreakingStrategy`, so it + * reached ELK as undefined and ELK's own default applied. This preset + * reproduces what `develop` actually renders, not what its schema claimed. + * Layering is ELK's default too — `develop` does not wire the option at all. + */ + legacy: { + layering: 'NETWORK_SIMPLEX', + placement: 'BRANDES_KOEPF', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'GREEDY', + }, + /** As `default`, but shorter back edges on graphs that have many. */ + depthFirst: { + layering: 'NETWORK_SIMPLEX', + placement: 'LINEAR_SEGMENTS', + containerPlacement: 'NETWORK_SIMPLEX', + cycleBreaking: 'DEPTH_FIRST', + }, +}; /** * Resolve a preset name, falling back to `default` for an unknown one. @@ -783,7 +796,8 @@ function createRootElkGraph( layoutOptions: { 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.algorithm': algorithm, - 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy ?? preset.placement, + 'elk.layered.nodePlacement.strategy': + data4Layout.config.elk?.nodePlacementStrategy ?? preset.placement, 'elk.layered.nodePlacement.bk.fixedAlignment': data4Layout.config.elk?.nodePlacementAlignment ?? DEFAULT_NODE_PLACEMENT_ALIGNMENT, 'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges, @@ -1240,6 +1254,12 @@ export function evenGroupFrames( if (width < labelFloor) { x -= (labelFloor - width) / 2; width = labelFloor; + // Still only ever pull IN. A title wider than the frame ELK sized is a + // frame ELK did not reserve for its own title, and widening it here would + // paper over that while breaking the one guarantee this pass makes. + const origRight = origin.posX + group.width!; + x = Math.max(origin.posX, Math.min(x, origRight - width)); + width = Math.min(width, group.width!); } const height = bottom - top; if (height <= 0 || width <= 0) { diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 8c850bc88ed..1dfa7499ca3 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -155,11 +155,14 @@ export interface MermaidConfig { * edge down a channel whose row rarely lines up with that port exactly, * leaving a staircase of a few pixels right at the border. With rounded * corners the two micro-bends land on top of each other and read as a - * kink. Enabling this moves the endpoint onto the channel row — still on - * the node's border — and drops the step. + * kink. Enabling this moves the channel onto the port's row and drops + * the step, so the edge draws as one straight line and both ports stay + * exactly where the layout put them. * * Only the step next to a node is touched, and only when the edge * continues the same way afterwards, so a real turn is never collapsed. + * An edge is left alone entirely when moving its run would drag the far + * port, or would introduce a crossing. * */ straightenEdges?: boolean; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts index 5c3ccddded9..59179da7461 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts @@ -248,23 +248,24 @@ describe('lineJump', () => { }); it('shrinks jump radii when adjacent crossings would overlap', () => { - // Two crossings only 1.0 apart on a horizontal segment, but radius is - // 1.0 so a naive rewrite would invert the path. The two arcs must - // shrink to fit (each ≤ half the gap) and stay monotonic along the - // segment. + // Two crossings 1.6 apart on a horizontal segment, with radius 1.0 — a + // naive rewrite would invert the path. The two arcs must shrink to fit + // (each <= half the gap) and stay monotonic along the segment. 1.6 is + // wide enough that the shrunk radius still clears the minimum-useful + // bar; the case where it does not is the test below. const edges: EdgeGeom[] = [ { id: 'v1', points: [ - { x: 4.5, y: 0 }, - { x: 4.5, y: 10 }, + { x: 4.2, y: 0 }, + { x: 4.2, y: 10 }, ], }, { id: 'v2', points: [ - { x: 5.5, y: 0 }, - { x: 5.5, y: 10 }, + { x: 5.8, y: 0 }, + { x: 5.8, y: 10 }, ], }, { @@ -284,14 +285,49 @@ describe('lineJump', () => { expect(d.startsWith('M0,5 ')).toBe(true); expect(d.endsWith(' L10,5')).toBe(true); // Sweep flag 0 for horizontal +x segments, and radius clamped to at - // most half the 1.0 gap between the two crossings. + // most half the 1.6 gap between the two crossings. const firstArcMatch = /A([\d.]+),([\d.]+) 0 0 1 ([\d.]+),5/.exec(d); expect(firstArcMatch).not.toBeNull(); const firstArcRadius = parseFloat(firstArcMatch![1]); - expect(firstArcRadius).toBeLessThanOrEqual(0.5); + expect(firstArcRadius).toBeLessThanOrEqual(0.8); expect(firstArcRadius).toBeGreaterThan(0); }); + it('drops both hops when crossings are too close to carry one each', () => { + // The adjacency clamp can shrink a radius as far as a bend can, and a hop + // shrunk that way is just as unreadable — at production radius 6, two + // crossings 4px apart would each get 2px, which does not clear the stroke + // being hopped. Same rule, applied after the clamp as well as before it. + const edges: EdgeGeom[] = [ + { + id: 'v1', + points: [ + { x: 4.7, y: 0 }, + { x: 4.7, y: 10 }, + ], + }, + { + id: 'v2', + points: [ + { x: 5.3, y: 0 }, + { x: 5.3, y: 10 }, + ], + }, + { + id: 'h', + points: [ + { x: 0, y: 5 }, + { x: 10, y: 5 }, + ], + }, + ]; + + const d = processEdgesWithJumps(edges, ARC_CONFIG).get('h')!; + + expect(d).not.toMatch(/A/); + expect(d).toBe('M0,5 L10,5'); + }); + it('returns plain polylines for every edge when disabled, even with crossings present', () => { const edges: EdgeGeom[] = [ { diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts index cbaf4afe4b5..f3581024469 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts @@ -302,8 +302,6 @@ interface JumpOnSegment { r: number; } -const MIN_JUMP_RADIUS = 1e-3; - /** * Shifts the first/last point inward along the edge direction by the amount * required for their arrow markers, matching `applyMarkerOffsetsToPoints` in @@ -510,7 +508,11 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf } for (const j of segJumps) { - if (j.r < MIN_JUMP_RADIUS) { + // Checked AGAIN after the adjacency pass, not only before it. That pass + // can halve a radius to keep two hops off each other, and a hop shrunk + // that way is just as unreadable as one squeezed by a bend — same rule, + // both times. Two crossings too close to carry a hop each carry none. + if (j.r < minUsefulRadius) { continue; } parts.push(...emitJump(j, ux, uy, sweep, config.jumpStyle)); diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 647633f68e4..c45088011a2 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -181,11 +181,14 @@ properties: edge down a channel whose row rarely lines up with that port exactly, leaving a staircase of a few pixels right at the border. With rounded corners the two micro-bends land on top of each other and read as a - kink. Enabling this moves the endpoint onto the channel row — still on - the node's border — and drops the step. + kink. Enabling this moves the channel onto the port's row and drops + the step, so the edge draws as one straight line and both ports stay + exactly where the layout put them. Only the step next to a node is touched, and only when the edge continues the same way afterwards, so a real turn is never collapsed. + An edge is left alone entirely when moving its run would drag the far + port, or would introduce a crossing. type: boolean default: true lineHops: From fef9e3e51690baa03c7dc54ba9c62be0bb640182 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 20:17:35 +0200 Subject: [PATCH 17/31] fix(elk): paint the clamped frame width, not the wider title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `evenGroupFrames` refuses to widen a frame past what ELK sized it to: a title wider than its own frame is a frame ELK did not reserve room for, and widening it here would paper over that. The clamp did its job on `group.width` — and then the layout node took `Math.max(width, labelData.width)` and handed the wider value straight back. That is the width the frame is painted at: `clusters.js` sizes the rect from `node.width`. So a 100-wide ELK frame under a 200-wide title painted 200 wide, spilling 100 units outside the bounds ELK reserved. The max only ever differed from `width` in exactly the case the clamp had just refused — everywhere else `width` has already honoured the label floor — so it existed solely to undo the clamp two lines above it. Every existing `evenGroupFrames` test passed an empty `nodeById`, which left the branch that writes the painted node uncovered. Both sides of it are now tested: the clamped case (the regression, which fails with 200 against the expected 100 before this change) and the case where ELK left room for the title, so the fix cannot pass by simply always shrinking. --- .../src/__tests__/render.spec.ts | 62 +++++++++++++++++++ packages/mermaid-layout-elk/src/render.ts | 8 ++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index 4a415ec3520..7e3906ef6af 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -1015,6 +1015,68 @@ describe('clearContainerAlgorithmOptions', () => { expect(nodeDb.g.width).toBe(120); }); + it('paints the clamped frame, not the wider title, into the layout node', () => { + // Every other test here passes an empty `nodeById`, so the branch that + // writes the node the renderer actually paints from went uncovered — and + // that is where the clamp was being undone. + // + // A 100-wide frame under a 200-wide title. The clamp refuses to widen the + // frame, so the layout node must not report 200 either: `clusters.js` + // sizes the painted rect from `node.width`, so a 200 here paints a frame + // 100 units wider than ELK reserved. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 100, height: 148 }, + n: { id: 'n', offset: { posX: 10, posY: 48 }, width: 40, height: 76 }, + }; + const layoutNode = { id: 'g', x: 0, y: 0, width: 0, height: 0 } as any; + + evenGroupFrames( + [ + { + id: 'g', + isGroup: true, + labelData: { width: 200 }, + labels: [], + children: [{ id: 'n' }], + }, + ], + { nodeDb } as any, + new Map([['g', layoutNode]]) + ); + + expect(nodeDb.g.width).toBe(100); + expect(layoutNode.width).toBe(100); + expect(layoutNode.width).toBe(nodeDb.g.width); + }); + + it('still reports the honoured title floor when ELK left room for it', () => { + // The other side of the same branch: here the 200-wide title fits inside + // the 300 ELK gave, so the floor is honoured and the layout node carries + // it. Without this the fix above could pass by always shrinking. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 300, height: 148 }, + n: { id: 'n', offset: { posX: 24, posY: 48 }, width: 40, height: 76 }, + }; + const layoutNode = { id: 'g', x: 0, y: 0, width: 0, height: 0 } as any; + + evenGroupFrames( + [ + { + id: 'g', + isGroup: true, + labelData: { width: 200 }, + labels: [], + children: [{ id: 'n' }], + }, + ], + { nodeDb } as any, + new Map([['g', layoutNode]]) + ); + + expect(nodeDb.g.width).toBe(200); + expect(layoutNode.width).toBe(200); + }); + it('skips a group with no children rather than collapsing it', () => { const nodeDb: Record = { g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 200, height: 100 }, diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 96970a5732d..ac07155c1e3 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -1282,7 +1282,13 @@ export function evenGroupFrames( if (layoutNode) { layoutNode.x = group.x; layoutNode.y = group.y; - layoutNode.width = Math.max(width, elkNode.labelData?.width ?? 0); + // The clamp above, not the label floor. `width` has already honoured the + // floor wherever ELK left room for it; the only case where the label is + // still wider is the one the clamp just refused, so taking the max here + // would quietly undo it — and this is the width the frame is PAINTED at + // (`clusters.js` sizes the rect from `node.width`), so the frame would + // spill outside the bounds ELK reserved. + layoutNode.width = width; layoutNode.height = height; } } From 7f911e222a2334ff82f1fec76396b78b6b6ba7ea Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 27 Aug 2026 20:30:42 +0200 Subject: [PATCH 18/31] refactor(line-hops): drop the redundant second path map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pathById` was exactly `pathByDataId` filtered to the ids present in `edges`, and the only lookup against it ran over `renderedEdges` — which is built from that same filter, so every id in it is already a hit in `pathByDataId`. The second map could never answer differently. Two near-identically named maps in one function is how a wrong-map bug gets written later, so the lookup now reads `pathByDataId` directly. The `if (!pathEl)` guard stays: `Map.get` is still `Element | undefined` to the type checker. Raised as an optional nit in review. --- .../mermaid/src/rendering-util/rendering-elements/lineJump.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts index f3581024469..6b82c03f041 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts @@ -680,13 +680,11 @@ export function applyLineJumpsToSvg( } const renderedEdges: EdgeGeom[] = []; - const pathById = new Map(); for (const e of edges) { const pathEl = pathByDataId.get(e.id); if (!pathEl) { continue; } - pathById.set(e.id, pathEl); const decoded = decodeDataPoints(pathEl.getAttribute('data-points')); const points = decoded ?? e.points; renderedEdges.push({ ...e, points }); @@ -715,7 +713,7 @@ export function applyLineJumpsToSvg( continue; } - const pathEl = pathById.get(renderedEdge.id); + const pathEl = pathByDataId.get(renderedEdge.id); if (!pathEl) { continue; } From f7f21409a5da7c4bd31424025933e3adffeca149 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 09:11:47 +0200 Subject: [PATCH 19/31] fix(themes): address review on the redux colour-theme superset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing visual coverage and folds away the drift-shaped leftovers the review flagged. e2e coverage for the diagrams this change exists to fix. The existing redux/neo theme specs cover er, gitGraph, mindmap, requirement, sequence and timeline -- pie, gantt, user-journey and state were rendered under no redux colour theme anywhere, so the largest visual deltas had no safety net. `redux-color-chart- themes.spec.ts` renders all four across the four redux themes (16 snapshots), under the default `classic` look since none is neo-specific. Fixture sizes are deliberate: 12 pie slices to exercise the whole scale, 8 journey sections for all of fillType0..7, and 4 gantt sections because `.section1`/`.section3` share `altSectionBkgColor` and 3 would hide that it is half of every gantt's banding. Gantt bands now come off the categorical scale rather than duplicating two hex literals. Verified nothing between the gantt block and the `cScale` assignments reads `sectionBkgColor`, so the assignments move below the scale and use `cScale0` / `cScale1`; a future palette change now carries them along. `altSectionBkgColor` pulled into scope. Both base themes set it to 'white', which is correct on a white canvas -- at gantt's 20% band opacity it composites to nothing, so every other band reads as absent. On the #333 canvas the same literal composites to rgb(92,92,92), brighter than either tuned hue, so half of every gantt's banding fought the other half. Both colour themes now use the canvas colour, giving the intended absent band in either mode. Confirmed by compositing all four themes' bands and by rendering. Five dead assignments folded. The unconditional writes sat above pre-existing `|| ` fallbacks for the same variables, leaving two contradictory statements eight lines apart where the second looked live and was not. Folded into the existing `this.x = this.x || '#…'` lines. Behaviour is unchanged either way, since `calculate()` re-applies overrides after `updateColors()`. `bkgColorArray` asymmetry is now asserted rather than invisible. It is not populated for `redux-dark-color`: that array gates *fills* in `er/styles.ts`, `requirement/styles.js` and `sequence/svgDraw.js`, and the dark theme deliberately colours only borders there, so filling it would repaint ER entities, requirement boxes and sequence actors -- three diagrams outside this change. The spec now checks the length per theme and records why they differ, so the one asymmetry between the themes no longer passes the test written to catch drift between them. The spec header no longer claims both themes add the array. Reconciled the contradictory journey-label comments: labels resolve to the theme's `textColor`, and the hardcoded `.label text { fill: #333 }` rule in `user-journey/styles.js` does not win. The dark file already said this; the light one did not. Changeset shortened. --- .changeset/redux-color-theme-superset.md | 12 +- .../redux-color-chart-themes.spec.ts | 121 ++++++++++++++++++ .../themes/theme-redux-color-superset.spec.ts | 31 ++++- .../mermaid/src/themes/theme-redux-color.js | 40 +++--- .../src/themes/theme-redux-dark-color.js | 37 ++++-- 5 files changed, 201 insertions(+), 40 deletions(-) create mode 100644 e2e/rendering/redux-color-chart-themes.spec.ts diff --git a/.changeset/redux-color-theme-superset.md b/.changeset/redux-color-theme-superset.md index b65fd227cd8..5f2ab75af7a 100644 --- a/.changeset/redux-color-theme-superset.md +++ b/.changeset/redux-color-theme-superset.md @@ -2,14 +2,8 @@ 'mermaid': patch --- -fix(themes): `redux-color` and `redux-dark-color` now define every theme variable their base themes (`redux` / `redux-dark`) define, and pie, gantt and user-journey draw from the themes' colour palette instead of rendering monochrome. +fix(themes): `redux-color` and `redux-dark-color` now define every theme variable their base themes define, and pie, gantt and user-journey draw from the theme palette instead of rendering monochrome. -**Missing variables.** The colour themes were forked from the base themes by copy-paste and had drifted. `redux-color` was missing `stateEdgeLabelBackground` and `requirementEdgeLabelBackground` entirely, and silently re-derived `primaryBorderColor`, `clusterBkg`, `clusterBorder`, `altBackground` and `compositeTitleBackground` from the grey `primaryColor` instead of the tuned values in `redux`. `redux-dark-color` was missing `compositeBackground`, `altBackground`, `compositeTitleBackground`, `stateEdgeLabelBackground` and `requirementEdgeLabelBackground`. +The colour themes were forked from `redux` / `redux-dark` by copy-paste and had drifted: some variables were missing (`stateEdgeLabelBackground`, `requirementEdgeLabelBackground`), others silently re-derived from the grey `primaryColor` (`primaryBorderColor`, `clusterBkg`, `clusterBorder`, `altBackground`, `compositeTitleBackground`). -Visible effects: state and requirement diagram edge labels get a solid white (light) / `#16141F` (dark) backing plate instead of a grey box; flowchart, state and block subgraph containers get the `#F9F9FB` fill and `#BDBCCC` border; and borders derived from `primaryBorderColor` — gantt task borders, quadrant chart borders, C4 person borders, architecture group borders — get the dark `#28253D`-based border instead of light grey. - -**Monochrome chart diagrams.** Pie, gantt and user-journey read flat `pieN` / `fillTypeN` / `sectionBkgColor` variables rather than the `cScale` array, so they never picked up the colour themes' palette. Every value was a tint of a single pale lavender: `pie3` resolved to pure white in `redux-color` and to near-black in `redux-dark-color`, and both gantt section colours were white (light) or near-black (dark), so the section banding was invisible at the 20% opacity gantt paints it with. - -Now: pie slices are drawn from the theme's categorical scale, so a pie reads like a mindmap or treemap in the same theme; gantt section bands use two palette hues that survive the 20% opacity; and user-journey gets eight hue-distinct section fills — pale in the light theme, dark in the dark theme, since journey labels use the theme's `textColor`. - -The colour themes still differ from their base themes only on the palette: `borderColorArray`, `bkgColorArray`, the `cScale*` scale, and the pie/journey/gantt variables listed above. +Separately, pie, gantt and user-journey read flat `pieN` / `fillTypeN` / `sectionBkgColor` variables rather than the `cScale` array, so they never picked up the palette — `pie3` resolved to pure white in `redux-color` and near-black in `redux-dark-color`, and gantt had no visible section banding at all. They now use the theme's categorical scale. diff --git a/e2e/rendering/redux-color-chart-themes.spec.ts b/e2e/rendering/redux-color-chart-themes.spec.ts new file mode 100644 index 00000000000..50695156a1c --- /dev/null +++ b/e2e/rendering/redux-color-chart-themes.spec.ts @@ -0,0 +1,121 @@ +import { test } from '@playwright/test'; +import { imgSnapshotTest } from '../helpers/util.ts'; + +/** + * pie, gantt, user-journey and state are the diagrams whose palettes the redux colour + * themes actually change, and until now none of them were rendered under those themes + * anywhere in the suite. The existing redux/neo theme specs cover er, gitGraph, mindmap, + * requirement, sequence and timeline — so the diagrams with the largest visual delta had + * no safety net, which is how the original fork-and-drift went unnoticed. + * + * Unlike `sequenceDiagram-redux-themes.spec.ts` these run under the default `classic` + * look: none of the four is neo-specific, and the palette wiring under test is + * look-independent. + */ +const reduxThemes = ['redux', 'redux-color', 'redux-dark', 'redux-dark-color'] as const; + +/** Twelve slices, so the whole categorical scale is exercised and wrap-around is visible. */ +const pieDiagram = ` + pie title Slice palette + "Alpha" : 20 + "Bravo" : 16 + "Charlie" : 14 + "Delta" : 12 + "Echo" : 10 + "Foxtrot" : 8 + "Golf" : 7 + "Hotel" : 6 + "India" : 3 + "Juliett" : 2 + "Kilo" : 1 + "Lima" : 1 +`; + +/** + * Four sections, because gantt cycles four band classes: `.section0` takes + * `sectionBkgColor`, `.section1` and `.section3` take `altSectionBkgColor`, and + * `.section2` takes `sectionBkgColor2`. Three sections would cover `altSectionBkgColor` + * only once and hide that it is half of every gantt's banding. Also exercises the active, + * done and crit task states, which read separate variables. + */ +const ganttDiagram = ` + gantt + title Section banding + dateFormat YYYY-MM-DD + section Discovery + Interviews :done, a1, 2024-01-01, 12d + Synthesis :active, a2, after a1, 8d + section Design + Wireframes :b1, 2024-01-10, 10d + Review :crit, b2, after b1, 6d + section Build + Backend :c1, 2024-01-20, 14d + Frontend :c2, after c1, 12d + section Launch + Beta :d1, 2024-02-10, 8d +`; + +/** Eight sections, so all of fillType0..7 are exercised rather than just the first few. */ +const journeyDiagram = ` + journey + title Task and section fills + section Browse + Land on site: 5: Visitor + Search: 3: Visitor + section Cart + Add item: 4: Visitor + View cart: 3: Visitor + section Pay + Enter card: 2: Visitor + Confirm: 5: Visitor + section Fulfil + Pack: 4: Warehouse + Ship: 3: Warehouse + section Deliver + Handover: 5: Courier + section Support + Contact: 2: Visitor + section Return + Request: 1: Visitor + section Close + Archive: 4: Visitor +`; + +/** + * Composite states and transition labels, which is what exercises + * `compositeTitleBackground`, `altBackground` and `stateEdgeLabelBackground` — the three + * variables that were missing or untuned in the colour themes. + */ +const stateDiagram = ` + stateDiagram-v2 + [*] --> Idle + Idle --> Working: start + state Working { + [*] --> Fetching + Fetching --> Parsing: bytes ready + Parsing --> [*] + } + Working --> Idle: done + Working --> Failed: error + Failed --> Idle: retry + Failed --> [*] +`; + +const diagrams = { + pie: pieDiagram, + gantt: ganttDiagram, + 'user-journey': journeyDiagram, + state: stateDiagram, +} as const; + +test.describe('Chart diagrams - Redux colour themes', () => { + for (const theme of reduxThemes) { + test.describe(`Theme: ${theme}`, () => { + for (const [name, diagram] of Object.entries(diagrams)) { + test(`should render ${name} with the theme palette`, async ({ page }, testInfo) => { + await imgSnapshotTest(page, testInfo, diagram, { theme }); + }); + } + }); + } +}); diff --git a/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts index 39b52ba25b5..c2b301a2969 100644 --- a/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts +++ b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts @@ -1,7 +1,8 @@ /** * `redux-color` / `redux-dark-color` are the colour-carrying siblings of `redux` / - * `redux-dark`: they add `borderColorArray`, `bkgColorArray` and a real categorical - * palette on top of the same geometry and typography. + * `redux-dark`: they add `borderColorArray` and a real categorical palette on top of the + * same geometry and typography. Only the light theme adds `bkgColorArray` -- see the + * array test below for why the dark one deliberately does not. * * They were forked by copy-paste, so they had drifted: seven variables `redux` * defines were either missing (`stateEdgeLabelBackground`, @@ -43,6 +44,13 @@ const PALETTE_VARS = new Set([ // Gantt section banding. 'sectionBkgColor', 'sectionBkgColor2', + // The third gantt band. Both base themes set it to 'white', which is right on a white + // canvas -- at the 20% opacity gantt paints bands with, it composites to nothing, so + // every other band reads as absent. On the dark canvas the same literal composites to + // rgb(92,92,92), a grey brighter than either tuned hue, so half of every gantt's + // banding fought the other half. Both colour themes now use the canvas colour, which + // gives the intended "absent" band in either mode. + 'altSectionBkgColor', ]); const PAIRS = [ @@ -50,6 +58,23 @@ const PAIRS = [ ['redux-dark', 'redux-dark-color'], ] as const; +/** + * How many `bkgColorArray` entries each colour theme ships. The asymmetry is deliberate, + * not leftover drift: `bkgColorArray` is what gates *fills* in `er/styles.ts`, + * `requirement/styles.js` and `sequence/svgDraw.js`, and the dark theme intentionally + * colours only borders there, leaving box interiors on the dark canvas. Populating it + * would silently repaint ER entities, requirement boxes and sequence actors. + * + * It is asserted per theme rather than left unchecked so that the difference stays a + * recorded decision. An earlier version of this spec checked only `borderColorArray`, + * which meant the one asymmetry between the two themes passed the very test written to + * catch drift between them. + */ +const EXPECTED_BKG_COLORS: Record = { + 'redux-color': 12, + 'redux-dark-color': 0, +}; + describe.each(PAIRS)('%s -> %s', (baseName, colorName) => { const base = themes[baseName].getThemeVariables({}) as unknown as Record; const color = themes[colorName].getThemeVariables({}) as unknown as Record; @@ -73,7 +98,9 @@ describe.each(PAIRS)('%s -> %s', (baseName, colorName) => { it(`${colorName} provides the colour arrays ${baseName} does not`, () => { expect(base.borderColorArray).toBeUndefined(); + expect(base.bkgColorArray).toBeUndefined(); expect(color.borderColorArray).toHaveLength(12); + expect(color.bkgColorArray).toHaveLength(EXPECTED_BKG_COLORS[colorName]); }); }); diff --git a/packages/mermaid/src/themes/theme-redux-color.js b/packages/mermaid/src/themes/theme-redux-color.js index ee966bdac7f..4548395f281 100644 --- a/packages/mermaid/src/themes/theme-redux-color.js +++ b/packages/mermaid/src/themes/theme-redux-color.js @@ -151,13 +151,6 @@ class Theme { const primaryColor = '#ECECFE'; const secondaryColor = '#E9E9F1'; const tertiaryColor = adjust(primaryColor, { h: 180, l: 5 }); - // Section bands are painted at 20% opacity (gantt/styles.js `.section`), so the - // source colour has to be saturated to read at all -- the `primaryColor` tints this - // used before gave no banding. Literals rather than `cScale0`/`cScale1` because the - // categorical scale is not assigned until further down updateColors(). - this.sectionBkgColor = this.sectionBkgColor || '#f4a8ff'; // Fuchsia-300 - this.altSectionBkgColor = this.altSectionBkgColor || 'white'; - this.sectionBkgColor2 = this.sectionBkgColor2 || '#46ecd5'; // Teal-300 this.excludeBkgColor = this.excludeBkgColor || '#eeeeee'; this.taskBorderColor = this.taskBorderColor || this.primaryBorderColor; this.taskBkgColor = this.taskBkgColor || primaryColor; @@ -191,14 +184,12 @@ class Theme { this.transitionLabelColor = this.transitionLabelColor || this.textColor; /* The color of the text tables of the states*/ this.stateLabelColor = this.stateLabelColor || this.stateBkg || this.primaryTextColor; - this.compositeTitleBackground = '#F9F9FB'; - this.altBackground = '#F9F9FB'; - this.stateEdgeLabelBackground = '#FFFFFF'; + this.stateEdgeLabelBackground = this.stateEdgeLabelBackground || '#FFFFFF'; this.stateBkg = this.stateBkg || this.mainBkg; this.labelBackgroundColor = this.labelBackgroundColor || this.stateBkg; this.compositeBackground = this.compositeBackground || this.background || this.tertiaryColor; - this.altBackground = this.altBackground || '#f0f0f0'; - this.compositeTitleBackground = this.compositeTitleBackground || this.mainBkg; + this.altBackground = this.altBackground || '#F9F9FB'; + this.compositeTitleBackground = this.compositeTitleBackground || '#F9F9FB'; this.compositeBorder = this.compositeBorder || this.nodeBorder; this.innerEndBackground = this.nodeBorder; this.errorBkgColor = this.errorBkgColor || this.tertiaryColor; @@ -232,6 +223,22 @@ class Theme { // } // } + /* Gantt chart section banding. + * + * gantt/styles.js cycles four band classes: .section0 -> sectionBkgColor, + * .section1 and .section3 -> altSectionBkgColor, .section2 -> sectionBkgColor2. All + * are painted at 20% opacity, so the source colour has to be saturated to read at + * all -- the `primaryColor` tints used before composited to nothing. + * + * Assigned here rather than in the gantt block above so the two visible bands come + * off the categorical scale and follow it if the palette changes. altSectionBkgColor + * stays the canvas colour so every other band reads as absent, which is the same + * alternation `default` uses. + */ + this.sectionBkgColor = this.sectionBkgColor || this.cScale0; + this.sectionBkgColor2 = this.sectionBkgColor2 || this.cScale1; + this.altSectionBkgColor = this.altSectionBkgColor || this.background; + // Setup the inverted color for the set for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { this['cScaleInv' + i] = this['cScaleInv' + i] || invert(this['cScale' + i]); @@ -266,9 +273,12 @@ class Theme { this.classText = this.classText || this.textColor; /* user-journey */ - // Journey task labels are hardcoded to #333 in user-journey/styles.js, so these - // fills must stay light. The pale background array keeps them hue-distinct where - // the old tints of `primaryColor` were eight shades of the same lavender. + // Journey task labels resolve to the theme's `textColor` (#28253D here), so these + // fills have to stay light. user-journey/styles.js also carries a hardcoded + // `.label text { fill: #333 }` rule, but it does not win -- confirmed against the + // rendered DOM, not read off the stylesheet. The pale background array keeps the + // fills hue-distinct where the old tints of `primaryColor` were eight shades of the + // same lavender. for (let i = 0; i < 8; i++) { this['fillType' + i] = this['fillType' + i] || this.bkgColorArray[i]; } diff --git a/packages/mermaid/src/themes/theme-redux-dark-color.js b/packages/mermaid/src/themes/theme-redux-dark-color.js index 4991ae9f736..fc558ee3e5c 100644 --- a/packages/mermaid/src/themes/theme-redux-dark-color.js +++ b/packages/mermaid/src/themes/theme-redux-dark-color.js @@ -156,13 +156,6 @@ class Theme { /* Gantt chart variables */ - // Section bands are painted at 20% opacity (gantt/styles.js `.section`), so the - // source colour has to be saturated to read at all -- the `primaryColor` tints this - // used before gave no banding. Literals rather than `cScale0`/`cScale1` because the - // categorical scale is not assigned until further down updateColors(). - this.sectionBkgColor = this.sectionBkgColor || '#f4a8ff'; // Fuchsia-300 - this.altSectionBkgColor = this.altSectionBkgColor || 'white'; - this.sectionBkgColor2 = this.sectionBkgColor2 || '#46ecd5'; // Teal-300 this.excludeBkgColor = this.excludeBkgColor || '#eeeeee'; this.taskBorderColor = this.taskBorderColor || this.primaryBorderColor; this.taskBkgColor = this.taskBkgColor || this.primaryColor; @@ -196,15 +189,12 @@ class Theme { this.transitionLabelColor = this.transitionLabelColor || this.textColor; /* The color of the text tables of the states*/ this.stateLabelColor = this.stateLabelColor || this.stateBkg || this.primaryTextColor; - this.compositeBackground = '#16141F'; - this.altBackground = '#16141F'; - this.compositeTitleBackground = '#16141F'; - this.stateEdgeLabelBackground = '#16141F'; + this.stateEdgeLabelBackground = this.stateEdgeLabelBackground || '#16141F'; this.stateBkg = this.stateBkg || this.mainBkg; this.labelBackgroundColor = this.labelBackgroundColor || this.stateBkg; - this.compositeBackground = this.compositeBackground || this.background || this.tertiaryColor; - this.altBackground = this.altBackground || '#f0f0f0'; - this.compositeTitleBackground = this.compositeTitleBackground || this.mainBkg; + this.compositeBackground = this.compositeBackground || '#16141F'; + this.altBackground = this.altBackground || '#16141F'; + this.compositeTitleBackground = this.compositeTitleBackground || '#16141F'; this.compositeBorder = this.compositeBorder || this.nodeBorder; this.innerEndBackground = this.nodeBorder; this.errorBkgColor = this.errorBkgColor || this.tertiaryColor; @@ -237,6 +227,25 @@ class Theme { // } // } + /* Gantt chart section banding. + * + * gantt/styles.js cycles four band classes: .section0 -> sectionBkgColor, + * .section1 and .section3 -> altSectionBkgColor, .section2 -> sectionBkgColor2. All + * are painted at 20% opacity, so the source colour has to be saturated to read at + * all -- the `primaryColor` tints used before composited to nothing. + * + * Assigned here rather than in the gantt block above so the two visible bands come + * off the categorical scale and follow it if the palette changes. + * + * altSectionBkgColor is the canvas colour, not the inherited 'white': at 20% over + * the #333 canvas white composites to rgb(92,92,92), a grey brighter than both tuned + * hues, so half of every gantt's banding fought the other half. The canvas colour + * makes those bands read as absent instead, matching the light theme. + */ + this.sectionBkgColor = this.sectionBkgColor || this.cScale0; + this.sectionBkgColor2 = this.sectionBkgColor2 || this.cScale1; + this.altSectionBkgColor = this.altSectionBkgColor || this.background; + // Setup the inverted color for the set for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { this['cScaleInv' + i] = this['cScaleInv' + i] || invert(this['cScale' + i]); From 1348b4fe4863db8747ed1a8a406d82660749733d Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Fri, 28 Aug 2026 12:39:28 +0200 Subject: [PATCH 20/31] fix(elk): keep subgraph ports off the frame's corners, and default to depth-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to how ELK diagrams settle. `elk.spacing.portsSurrounding` was left at ELK's default of 0, which permits a port to sit exactly on a node's corner. A corner is the one boundary point with no side to leave from, so the edge left the vertex and then ran ALONG the frame's own edge before turning away. Subgraphs showed it first: an edge that crosses a subgraph boundary attaches to the frame rather than to a node inside it, and a frame is large enough for the result to be obvious. Reserving a margin at the ends of each side lets ELK keep ports off the corners itself, rather than the renderer correcting them afterwards — an endpoint fix-up was tried first and made it worse, moving the terminal to mid-side while leaving ELK's corner bend in place, so the edge hugged the border to get back to it. Over the elk-edge-cases corpus this takes fixtures with a corner endpoint from 8 of 30 to 3. The remaining three also occur under `legacy`, so they have another cause. 12 is chosen by measurement, not taste: the smallest value that clears the corner on that corpus. Not a free parameter — 30 reorders layers. `preset` also changes. Depth-first cycle breaking becomes `default`, since it gives shorter back edges on graphs that loop, which is most flowcharts that loop at all. The greedy-model-order triple that `default` named before is still reachable as `modelOrder`, and `depthFirst` stays as a name for what `default` now is, so a diagram can say depth-first rather than depend on the default staying put. --- .changeset/elk-layout-presets.md | 9 +- .changeset/elk-port-corner-spacing.md | 21 ++ .cspell/code-terms.txt | 3 + .../src/__tests__/render.spec.ts | 45 ++- .../src/elkOptionCatalogue.spec.ts | 34 ++ .../src/elkOptionCatalogue.ts | 292 ++++++++++++++++++ packages/mermaid-layout-elk/src/render.ts | 86 +++++- packages/mermaid/src/config.type.ts | 16 +- .../mermaid/src/schemas/config.schema.yaml | 15 +- 9 files changed, 495 insertions(+), 26 deletions(-) create mode 100644 .changeset/elk-port-corner-spacing.md create mode 100644 packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts create mode 100644 packages/mermaid-layout-elk/src/elkOptionCatalogue.ts diff --git a/.changeset/elk-layout-presets.md b/.changeset/elk-layout-presets.md index 26b8c425198..55af18a01c8 100644 --- a/.changeset/elk-layout-presets.md +++ b/.changeset/elk-layout-presets.md @@ -6,9 +6,10 @@ feat: `elk.preset` picks a named combination of the options that decide where no Three options settle node positions, and they sit in different phases of the layout: which layer a node lands in, where it goes within that layer, and which edges get reversed to make the graph acyclic. Choosing them well means knowing all three interact; `preset` names the combinations worth using. -- `default` — network simplex layering, linear segments placement, greedy model order cycle breaking. Keeps chains of nodes aligned. +- `default` — network simplex layering and placement with depth-first cycle breaking at the top level; subgraph contents are placed with Brandes-Koepf. Depth-first breaking gives shorter back edges on graphs that loop. - `legacy` — reproduces what earlier versions actually rendered: Brandes-Koepf placement with ELK's own greedy cycle breaking. -- `depthFirst` — as `default`, but breaks cycles depth first, which gives shorter back edges on graphs that have many. +- `modelOrder` — as `default`, but breaks cycles by greedy model order, which disturbs declaration order least at the cost of longer back edges. +- `depthFirst` — a name for what `default` already is, so a diagram can say depth-first rather than rely on the default staying put. ```yaml --- @@ -21,8 +22,8 @@ config: Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one option and leaves the rest in place, so a preset is a starting point rather than a lock. -**Node placement changes from `BRANDES_KOEPF` to `LINEAR_SEGMENTS`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour, and is the single switch for it — this is the net change against the last release, measured from what shipped rather than from any intermediate state. +**Node placement changes from `BRANDES_KOEPF` to `NETWORK_SIMPLEX`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour, and is the single switch for it — this is the net change against the last release, measured from what shipped rather than from any intermediate state. -Subgraphs are a separate case: their contents are placed with `NETWORK_SIMPLEX`, which balances a node against all of its neighbours and so keeps a group's nodes aligned with one another instead of drifting. That is a container setting and is not affected by `preset`. +Subgraph contents keep `BRANDES_KOEPF`, which is deliberately not the root's strategy: network simplex inside a frame produced routes that left the subgraph on its bounding-box corner. The two sides are tuned independently, so `nodePlacementStrategy` set explicitly still applies to both. Note that `legacy` uses `GREEDY` cycle breaking rather than the `GREEDY_MODEL_ORDER` the schema previously advertised. That default was declared in the schema but never listed in the shipped defaults, so it reached ELK as undefined and ELK's own default applied — `legacy` reproduces what was rendered, not what was documented. diff --git a/.changeset/elk-port-corner-spacing.md b/.changeset/elk-port-corner-spacing.md new file mode 100644 index 00000000000..b080bc19e89 --- /dev/null +++ b/.changeset/elk-port-corner-spacing.md @@ -0,0 +1,21 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: an edge no longer leaves a subgraph from the frame's corner. + +`elk.spacing.portsSurrounding` was left at ELK's default of `0`, which permits a +port to sit exactly on a node's corner. A corner is the one boundary point with +no side to leave from, so the edge came out of the vertex and then ran ALONG the +frame's own edge before turning away from it. Subgraphs showed it first, because +an edge that crosses a subgraph boundary attaches to the frame rather than to a +node inside it, and a frame is large enough for the result to be obvious. + +A margin is now reserved at the ends of every side, so ELK keeps ports off the +corners itself rather than the renderer correcting them afterwards. Over the +`elk-edge-cases` corpus this takes the fixtures with a corner endpoint from 8 of +30 down to 3. + +The value is 12, chosen by measurement: it is the smallest that clears the +corner on that corpus. It is not a free parameter — 30 was tried and reorders +layers. diff --git a/.cspell/code-terms.txt b/.cspell/code-terms.txt index 2b4cbffeff2..e18eae01847 100644 --- a/.cspell/code-terms.txt +++ b/.cspell/code-terms.txt @@ -53,6 +53,7 @@ DOUBLECIRCLEEND DOUBLECIRCLESTART DQUOTE DSTART +DUMMYNODE EBNF edgesep Eiglsperger @@ -101,6 +102,8 @@ minlen Mstartx MULT Naur +NIKOLOV +NODECOUNT NODIR nonterminal NSTR diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index 7e3906ef6af..fcb387bca37 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -9,6 +9,7 @@ import { findCyclicEntryNodes, prepareLayoutForElk, resolveContainerAlgorithm, + resolveElkPreset, runElkLayoutCore, } from '../render.js'; @@ -131,10 +132,10 @@ describe('buildSubgraphLayoutOptions', () => { const opts = buildSubgraphLayoutOptions({}, undefined, 'layered'); expect(opts['elk.layered.mergeEdges']).toBeUndefined(); // With no config at all the `default` preset supplies the placement - // strategy. Containers get NETWORK_SIMPLEX where the root gets - // LINEAR_SEGMENTS: balancing a node against all of its neighbours is what - // keeps a group's nodes aligned with each other rather than drifting. - expect(opts['elk.layered.nodePlacement.strategy']).toBe('NETWORK_SIMPLEX'); + // strategy. Containers are BRANDES_KOEPF while the root is NETWORK_SIMPLEX: + // network simplex inside a frame produced routes that left a subgraph on + // its bounding-box corner, so containers keep the strategy that does not. + expect(opts['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); expect(opts['elk.layered.nodePlacement.bk.fixedAlignment']).toBe('NONE'); }); @@ -157,8 +158,11 @@ describe('buildSubgraphLayoutOptions', () => { // reach containers too — leaving them on the new strategy would make it a // half-restore that still lays subgraph contents out differently. expect(placement('legacy')).toBe('BRANDES_KOEPF'); - expect(placement('depthFirst')).toBe('NETWORK_SIMPLEX'); - expect(placement('default')).toBe('NETWORK_SIMPLEX'); + // `default` and `depthFirst` place the ROOT with NETWORK_SIMPLEX but keep + // containers on BRANDES_KOEPF — the two sides are tuned separately on + // purpose, so a change to one must not be assumed to carry to the other. + expect(placement('depthFirst')).toBe('BRANDES_KOEPF'); + expect(placement('default')).toBe('BRANDES_KOEPF'); }); it('names the placement option once, fully qualified', () => { @@ -1089,3 +1093,32 @@ describe('clearContainerAlgorithmOptions', () => { }); }); }); + +describe('resolveElkPreset', () => { + it('breaks cycles depth first by default', () => { + // Depth-first took over as the default because it gives shorter back edges + // on graphs that loop; the greedy-model-order triple it displaced is still + // reachable, under its own name. + expect(resolveElkPreset(undefined).cycleBreaking).toBe('DEPTH_FIRST'); + expect(resolveElkPreset('default').cycleBreaking).toBe('DEPTH_FIRST'); + expect(resolveElkPreset('modelOrder').cycleBreaking).toBe('GREEDY_MODEL_ORDER'); + expect(resolveElkPreset('legacy').cycleBreaking).toBe('GREEDY'); + }); + + it('keeps depthFirst as a name for what default already is', () => { + // Not a distinct combination — a label, so a diagram can say depth-first + // rather than depend on the default staying put. + expect(resolveElkPreset('depthFirst')).toEqual(resolveElkPreset('default')); + }); + + it('differs from default in cycle breaking alone for modelOrder', () => { + const { cycleBreaking: _a, ...restDefault } = resolveElkPreset('default'); + const { cycleBreaking: _b, ...restModelOrder } = resolveElkPreset('modelOrder'); + expect(restModelOrder).toEqual(restDefault); + }); + + it('falls back to default for an unknown name, including __proto__', () => { + expect(resolveElkPreset('nope')).toEqual(resolveElkPreset('default')); + expect(resolveElkPreset('__proto__')).toEqual(resolveElkPreset('default')); + }); +}); diff --git a/packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts b/packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts new file mode 100644 index 00000000000..9e398fe5bd8 --- /dev/null +++ b/packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + EDGE_ROUTING_OPTIONS, + PLACEMENT_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES, + SUBGRAPH_EXPERIMENT_OVERRIDES, +} from './elkOptionCatalogue.js'; + +/** + * Every catalogue block is merged last over the shipping layout options, so an + * option left uncommented changes every ELK diagram for every user. That is the + * point while an option is being tried, and a release blocker afterwards. + * + * These assertions are the "off" half of the switch: leave one on and the build + * fails here, naming the keys, rather than the change reaching a release as a + * silent rendering diff. + */ +describe('ELK option catalogue', () => { + const blocks = { + PLACEMENT_OPTIONS, + EDGE_ROUTING_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES, + SUBGRAPH_EXPERIMENT_OVERRIDES, + }; + + for (const [name, block] of Object.entries(blocks)) { + it(`ships with nothing switched on in ${name}`, () => { + expect( + Object.keys(block), + 're-comment these in elkOptionCatalogue.ts before committing' + ).toEqual([]); + }); + } +}); diff --git a/packages/mermaid-layout-elk/src/elkOptionCatalogue.ts b/packages/mermaid-layout-elk/src/elkOptionCatalogue.ts new file mode 100644 index 00000000000..954f57b216a --- /dev/null +++ b/packages/mermaid-layout-elk/src/elkOptionCatalogue.ts @@ -0,0 +1,292 @@ +/** + * A catalogue of the ELK options that affect layout, with every valid value + * listed and one line saying what it does. + * + * THIS FILE IS LIVE. Uncomment an option below and it applies to every ELK + * diagram on the next rebuild — no edit to `render.ts`, nothing to paste + * anywhere. Re-comment it to switch it back off. That is the whole workflow. + * + * Each block is merged over the shipping `layoutOptions` as the LAST word, so + * an entry beats the `elk.preset`, any `config.elk.*` key the diagram sets, and + * the DDLT sweep. Each block has exactly ONE destination: + * + * PLACEMENT_OPTIONS ─┐ + * EDGE_ROUTING_OPTIONS ├─→ root graph (`createRootElkGraph`) + * ROOT_EXPERIMENT_OVERRIDES ─┘ + * SUBGRAPH_EXPERIMENT_OVERRIDES → every container (`buildSubgraphLayoutOptions`) + * + * Root is right for layering, node placement, cycle breaking and edge routing: + * `elk.hierarchyHandling` is `INCLUDE_CHILDREN`, so one pass governs nodes + * inside frames too. It is WRONG for `spacing.*`, `elk.padding` and + * `nodeLabels.placement` — containers get their own set, so those do nothing at + * the root and belong in `SUBGRAPH_EXPERIMENT_OVERRIDES`. Several options here + * were written off as "no effect" before anyone noticed that. + * + * Do not merge a block into both sides. The shipping config gives containers a + * DIFFERENT node placement from the root (`preset.containerPlacement` vs + * `preset.placement`); forcing one value on both measures a layout the product + * can never produce. + * + * Two things to know before reading a result: + * + * - Most keys are listed several times, once per valid value. Uncomment TWO + * lines of the same key and it is a duplicate-key error, which is the + * intended guard rather than a silent last-one-wins. + * - Anything here that names a key already wired to `config.elk.*` silently + * disables that config for every diagram while it is live. `elk.cycleBreakingStrategy` + * was dead this way, and it took a bisect against the raw ELK option to spot. + * + * Everything here MUST be commented out on `develop`. `elkOptionCatalogue.spec.ts` + * asserts that, so an option left switched on fails the build instead of + * shipping as a silent rendering change for every user. + */ + +export const PLACEMENT_OPTIONS: Record = { + // ─── Layering — which layer a node lands in (the column in LR, row in TB) ─── + // Coarsest placement decision there is; relocates 38-48% of nodes. + // 'elk.layered.layering.strategy': 'COFFMAN_GRAHAM', + // 'elk.layered.layering.strategy': 'NETWORK_SIMPLEX', // ELK default: fewest long edges * + // 'elk.layered.layering.strategy': 'LONGEST_PATH', // every node as late as possible + // 'elk.layered.layering.strategy': 'LONGEST_PATH_SOURCE', // same, measured from sources + // 'elk.layered.layering.strategy': 'MIN_WIDTH', // narrower drawing, longer edges + // 'elk.layered.layering.strategy': 'STRETCH_WIDTH', // wider drawing, shorter edges + // 'elk.layered.layering.strategy': 'INTERACTIVE', // honours positions already on nodes + // Cap on how many nodes COFFMAN_GRAHAM puts in one layer; ignored by the rest. + // 'elk.layered.layering.coffmanGraham.layerBound': 2, + // 'elk.layered.layering.coffmanGraham.layerBound': 4, // ELK default; taller and narrower + // Pulls nodes into earlier layers to cut dummy nodes on long edges. + // 'elk.layered.layering.nodePromotion.strategy': 'NONE', // ELK default + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV', + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV_PIXEL', + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV_IMPROVED', + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV_IMPROVED_PIXEL', + // 'elk.layered.layering.nodePromotion.strategy': 'DUMMYNODE_PERCENTAGE', + // 'elk.layered.layering.nodePromotion.strategy': 'NODECOUNT_PERCENTAGE', + // 'elk.layered.layering.nodePromotion.strategy': 'NO_BOUNDARY', + // ─── Crossing minimisation — the order of nodes within a layer ─── + // How node order inside each layer is chosen. + // 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', // ELK default + //'elk.layered.crossingMinimization.strategy': 'INTERACTIVE', // keeps existing order + // 'elk.layered.crossingMinimization.strategy': 'NONE', // declaration order, no sweep + // Extra pass that swaps adjacent node pairs when it removes crossings. + // 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', // ELK default + // 'elk.layered.crossingMinimization.greedySwitch.type': 'ONE_SIDED', + // 'elk.layered.crossingMinimization.greedySwitch.type': 'OFF', + // How hard declaration order is defended against crossing reduction. + // 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + // 'elk.layered.considerModelOrder.strategy': 'NONE', // ignore declaration order + // 'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES', // order edges, let nodes move + // 'elk.layered.considerModelOrder.strategy': 'PREFER_NODES', // order nodes, let edges move + // ─── Node placement — the coordinate within the layer ─── + // Wired to `elk.nodePlacementStrategy`; uncomment to override that config. + // 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', // our default: balanced + // 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', // ELK default: straight long edges + // 'elk.layered.nodePlacement.strategy': 'LINEAR_SEGMENTS', // keeps chains aligned + // 'elk.layered.nodePlacement.strategy': 'SIMPLE', // cheapest, least tidy + // Shifts nodes to straighten edges rather than centre them in the layer. + // 'elk.layered.nodePlacement.favorStraightEdges': true, + // 'elk.layered.nodePlacement.favorStraightEdges': false, + // Brandes-Koepf only: which of its four candidate alignments to keep. + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'NONE', // pick the shortest result + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // average all four + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'LEFTUP', + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'RIGHTUP', + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'LEFTDOWN', + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'RIGHTDOWN', + // Brandes-Koepf only: post-pass that trades compactness for straighter edges. + // 'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS', + // 'elk.layered.nodePlacement.bk.edgeStraightening': 'NONE', // ELK default + // Network-simplex only: what the placer is allowed to stretch to straighten edges. + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NONE', // ELK default + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE', + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'PORT_POSITION', + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE_WHERE_SPACE_PERMITS', + // ─── Cycles and hierarchy ─── + // Which edges get reversed to make the graph acyclic; decides which ones detour. + // Wired to `elk.cycleBreakingStrategy`; uncomment to override that config. + // 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER', // our default + // 'elk.layered.cycleBreaking.strategy': 'GREEDY', // ELK default; short back edges, +20% total + // 'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST', // middle ground, +6% total + // 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER', // reverse purely by declaration order + // 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', // reverse by existing positions + // Whether subgraphs are laid out with the parent or in their own coordinate system. + // 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', // our default, one global pass + // 'elk.hierarchyHandling': 'SEPARATE_CHILDREN', // shorter edges, far more constraint violations + // Post-pass that pulls nodes back towards one side to reclaim space. + // 'elk.layered.compaction.postCompaction.strategy': 'NONE', // ELK default + // 'elk.layered.compaction.postCompaction.strategy': 'LEFT', + // 'elk.layered.compaction.postCompaction.strategy': 'RIGHT', + // 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONSTRAINT_LOCKING', + // 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONNECTION_LOCKING', + // 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH', + // ─── Spacing and labels ─── + // Base spacing everything else derives from; the single biggest lever on size. + // 'spacing.baseValue': 40, + // 'spacing.baseValue': 20, // ELK default — collapses this corpus, 13/14 invalid + // Where a container's own title sits inside its frame. + // 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', + // ─── Measured inert on this corpus — a null result here means nothing ─── + // Overwritten straight after createRootElkGraph by the diagram's own direction. + // 'elk.direction': 'UP', + // ELK ignores this key in every spelling; the gap derives from spacing.baseValue. + // 'elk.spacing.edgeNode': 20, + // Only applies when wrapping.strategy is on, and it is off. + // 'elk.layered.wrapping.cutting.strategy': 'ARD', + // Routes reversed edges in their own band. No effect measured here. + // 'elk.layered.feedbackEdges': true, + // ─── Tried and parked ─── + // 'elk.layered.wrapping.strategy': 'MULTI_EDGE', + // 'elk.layered.wrapping.strategy': 'SINGLE_EDGE', + // 'elk.layered.crossingMinimization.semiInteractive': true, + // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1, + // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0, + // 'elk.layered.wrapping.validify.strategy': 'LOOK_BACK', + // 'elk.insideSelfLoops.activate': true, + // 'elk.separateConnectedComponents': true, + // 'elk.alignment': 'LEFT', +}; + +/** + * Edge ROUTING options. Live, on the same terms as {@link PLACEMENT_OPTIONS} — + * merged over root and subgraph alike, and spread after it so these win. + * + * Routing decides how an edge is drawn between the layers it was already + * assigned to. It cannot change which way round the graph an edge travels — a + * long detour is a back edge, and that is settled in cycle breaking and + * layering, both of which live in {@link PLACEMENT_OPTIONS}. + * + * MUST be fully commented out on `develop`. The routing options actually in + * force ship in `createRootElkGraph`: `edgeRouting.selfLoopDistribution`, + * `unnecessaryBendpoints` and `mergeHierarchyEdges`. + */ +export const EDGE_ROUTING_OPTIONS: Record = { + // Shape of every edge. ORTHOGONAL is ELK's default and what the adapter expects. + // 'elk.edgeRouting': 'ORTHOGONAL', + // 'elk.edgeRouting': 'POLYLINE', // diagonal runs, fewer bends + // 'elk.edgeRouting': 'SPLINES', // curved; validateLayout treats these as non-orthogonal + // 'elk.edgeRouting': 'UNDEFINED', // let the algorithm decide + // Drops bends that do not change the path. Already on in the literal below. + // 'elk.layered.unnecessaryBendpoints': true, + // 'elk.layered.unnecessaryBendpoints': false, + // Routes reversed edges in their own band instead of among the forward ones. + // The obvious candidate for a back-edge detour — measured inert on this corpus. + // 'elk.layered.feedbackEdges': true, + // 'elk.layered.feedbackEdges': false, + // Lets edges that meet at a node share a trunk. Collapses arriving and leaving + // onto ONE handle, which can imply a connection that does not exist. + // 'elk.layered.mergeEdges': true, + // 'elk.layered.mergeEdges': false, + // Same, for edges that cross a subgraph boundary. On in the literal below. + // 'elk.layered.mergeHierarchyEdges': true, + // 'elk.layered.mergeHierarchyEdges': false, + // How much straightening an edge is worth relative to other objectives. + // Also settable per edge, which is the targeted way to rescue one bad route. + // 'elk.layered.priority.straightness': 0, + // 'elk.layered.priority.shortness': 0, + // 'elk.layered.priority.direction': 1, + // ─── Self loops ─── + // Which sides a node's self loops are spread across. EQUALLY ships below. + // 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY', + // 'elk.layered.edgeRouting.selfLoopDistribution': 'NORTH', + // 'elk.layered.edgeRouting.selfLoopDistribution': 'NORTH_SOUTH', + // Whether stacked self loops nest or sit side by side. + // 'elk.layered.edgeRouting.selfLoopOrdering': 'STACKED', + // 'elk.layered.edgeRouting.selfLoopOrdering': 'SEQUENCED', + // Draw self loops inside the node rather than hanging off it. + // 'elk.insideSelfLoops.activate': true, + // ─── Spline and polyline tuning (only read by the matching edgeRouting) ─── + // How closely splines hug the orthogonal path they replace. + // 'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE', + // 'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE_SOFT', + // 'elk.layered.edgeRouting.splines.mode': 'SLOPPY', + // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1, + // Width of the band a POLYLINE edge may slope through. + // (Was left uncommented while this block was inert; commented now that it is + // live, since it would otherwise be permanently on for every diagram.) + // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0, + // ─── Lanes and clearance ─── + // Gap between two edges sharing a lane; too small trips the proximity checks. + // 'spacing.edgeEdge': 10, + // 'elk.layered.spacing.edgeEdgeBetweenLayers': 20, + // Gap between an edge and a node it passes. Ignored at root; the subgraph + // value derives from `spacing.baseValue` at roughly half. + // 'spacing.edgeNode': 20, + // 'elk.layered.spacing.edgeNodeBetweenLayers': 80, + // ─── Edge labels ─── + // Which side of its edge a label sits on. + // 'elk.layered.edgeLabels.sideSelection': 'SMART_DOWN', + // 'elk.layered.edgeLabels.sideSelection': 'SMART_UP', + // 'elk.layered.edgeLabels.sideSelection': 'ALWAYS_UP', + // 'elk.layered.edgeLabels.sideSelection': 'ALWAYS_DOWN', + // 'elk.layered.edgeLabels.sideSelection': 'DIRECTION_UP', + // 'elk.layered.edgeLabels.sideSelection': 'DIRECTION_DOWN', + // Which layer a centre label is parked in when the edge spans several. + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'MEDIAN_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'HEAD_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'TAIL_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'SPACE_EFFICIENT_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'WIDEST_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'CENTER_LAYER', +}; + +/* ──────────────────────────────────────────────────────────────────────────── + * THE SWITCH + * + * Everything above is reference material — inert, imported by nothing, there to + * be read and copied from. The two objects below are the opposite: they ARE + * imported, and whatever they contain is merged over the shipping layout + * options as the last word. That is the on/off switch. + * + * To try an option: copy its line out of the catalogue above into the matching + * object below and uncomment it. To switch back off: re-comment it. Nothing in + * `render.ts` needs editing either way, so an experiment can never be left + * behind as a stray edit in production code — which is how the previous round + * of these ended up deleted rather than kept. + * + * Both MUST be empty on `develop`. `elkOptionCatalogue.spec.ts` asserts exactly + * that, so an override left switched on fails the build instead of shipping. + * + * Which object to use matters more than it looks: options set on the ROOT graph + * do NOT reach subgraphs — containers get their own set — so `spacing.*`, + * `elk.padding` and `nodeLabels.placement` do nothing at the root. Several + * options in the catalogue above were written off as "no effect" until they + * were moved to the subgraph side. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Scratch overrides merged last over the ROOT graph's `layoutOptions`, after + * `elk.preset` and every `config.elk.*` key have been resolved — so an entry + * here beats the shipping default AND the diagram's own config. + * + * Governs layering, node placement, cycle breaking and edge routing for the + * top-level graph. MUST be empty on `develop`. + */ +export const ROOT_EXPERIMENT_OVERRIDES: Record = { + // 'elk.layered.layering.strategy': 'COFFMAN_GRAHAM', + // 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', + // 'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST', + // 'elk.edgeRouting': 'POLYLINE', + // 'spacing.baseValue': 60, +}; + +/** + * Scratch overrides merged last over every SUBGRAPH's `layoutOptions`, after + * the per-container algorithm branch — so an entry here also beats the + * rectpacking and directional-subgraph blocks. + * + * This is the side that owns spacing, padding and label placement inside a + * frame. MUST be empty on `develop`. + */ +export const SUBGRAPH_EXPERIMENT_OVERRIDES: Record = { + // 'spacing.nodeNode': 60, + // 'elk.spacing.edgeEdge': 10, + // 'elk.layered.spacing.edgeNodeBetweenLayers': 80, + // 'elk.padding': '[top=24,left=24,bottom=24,right=24]', + // 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', + // Equal-width subgraph frames. Tried and DOES NOT WORK: the options reach + // ELK intact, but every container is forced back to INCLUDE_CHILDREN by + // `setIncludeChildrenPolicy` (cross-boundary edges), and in that mode ELK + // sizes a compound node to its contents and ignores the minimum. + // 'nodeSize.constraints': '[MINIMUM_SIZE]', + // 'nodeSize.minimum': '(446, 0)', +}; diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index ac07155c1e3..485347b4ebe 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -9,6 +9,12 @@ import { curveLinear } from 'd3'; import ELK from 'elkjs/lib/elk.bundled.js'; import { type TreeData, findCommonAncestor } from './find-common-ancestor.js'; import { applyElkLineJumps } from './lineHops.js'; +import { + EDGE_ROUTING_OPTIONS, + PLACEMENT_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES, + SUBGRAPH_EXPERIMENT_OVERRIDES, +} from './elkOptionCatalogue.js'; import { type P, @@ -136,6 +142,13 @@ const ARROW_MAP: Record = { double_arrow_circle: ['arrow_circle', 'arrow_circle'], }; const DEFAULT_NODE_PLACEMENT_ALIGNMENT = 'NONE'; + +/** + * Margin reserved at the ends of each side of a node, so that a port cannot be + * placed on a corner. Spelled as an ELK margin because `spacing.portsSurrounding` + * takes one. + */ +const PORTS_SURROUNDING = '[top=12,left=12,bottom=12,right=12]'; /** Padding between a subgraph frame and its children. ELK's own default is 12. */ const SUBGRAPH_PADDING = 24; /** @@ -359,6 +372,10 @@ export function buildSubgraphLayoutOptions( // PORT_POSITION lets a node shift so an edge can leave straight rather than // bending immediately off the port. 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'PORT_POSITION', + // Keep a frame's ports off its own corners. See the note in + // `createRootElkGraph`; a container is where this bites hardest, because a + // cross-boundary edge attaches to the frame rather than to a node inside it. + 'elk.spacing.portsSurrounding': PORTS_SURROUNDING, }; // Apply per-group algorithm from metadata (e.g. @{algorithm: elk.box}). @@ -399,6 +416,11 @@ export function buildSubgraphLayoutOptions( layoutOptions['elk.direction'] = dir2ElkDirection(node.dir); layoutOptions['elk.hierarchyHandling'] = 'SEPARATE_CHILDREN'; } + + // Container-scoped experiments. Spacing, padding and label placement only + // bite here — an option set on the root never reaches inside a frame. + Object.assign(layoutOptions, SUBGRAPH_EXPERIMENT_OVERRIDES); + return layoutOptions; } @@ -739,12 +761,25 @@ const ELK_PRESETS: Record< string, { layering: string; placement: string; containerPlacement: string; cycleBreaking: string } > = { - /** Keeps chains of nodes aligned. */ + /** + * Network simplex at the root, Brandes-Koepf inside frames, cycles broken + * depth first. + * + * Depth-first cycle breaking gives shorter back edges on graphs that have + * many of them, which is most flowcharts that loop at all. `modelOrder` is + * the same triple with the greedy-model-order breaking this used to carry. + * + * Containers deliberately do NOT follow the root's placement. Network simplex + * inside a frame produced routes that left a subgraph on its bounding-box + * corner, so the two sides are tuned separately: changing one is not a reason + * to change the other, and `legacy` keeps both on the strategy that shipped + * before. + */ default: { layering: 'NETWORK_SIMPLEX', - placement: 'LINEAR_SEGMENTS', - containerPlacement: 'NETWORK_SIMPLEX', - cycleBreaking: 'GREEDY_MODEL_ORDER', + placement: 'NETWORK_SIMPLEX', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'DEPTH_FIRST', }, /** * What shipped before presets: straighter long edges, less alignment. @@ -761,11 +796,26 @@ const ELK_PRESETS: Record< containerPlacement: 'BRANDES_KOEPF', cycleBreaking: 'GREEDY', }, - /** As `default`, but shorter back edges on graphs that have many. */ + /** + * As `default`, but breaks cycles by greedy model order — which reverses the + * edges that disturb declaration order least, at the cost of longer back + * edges. This is the triple `default` named before depth-first took over. + */ + modelOrder: { + layering: 'NETWORK_SIMPLEX', + placement: 'NETWORK_SIMPLEX', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'GREEDY_MODEL_ORDER', + }, + /** + * Kept as a name for what `default` now is, so diagrams that asked for + * depth-first breaking by name keep saying what they mean. Identical to + * `default` on purpose — not a distinct combination. + */ depthFirst: { layering: 'NETWORK_SIMPLEX', - placement: 'LINEAR_SEGMENTS', - containerPlacement: 'NETWORK_SIMPLEX', + placement: 'NETWORK_SIMPLEX', + containerPlacement: 'BRANDES_KOEPF', cycleBreaking: 'DEPTH_FIRST', }, }; @@ -827,6 +877,18 @@ function createRootElkGraph( 'elk.layered.wrapping.multiEdge.improveWrappedEdges': true, 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY', 'elk.layered.mergeHierarchyEdges': true, + // Reserve a margin at the ends of every side so a port cannot land on a + // corner. ELK's default is 0, which permits it — and a corner is the one + // boundary point with no side to leave from, so the edge came out of the + // vertex and then ran ALONG the box's own edge before turning away. It + // showed up on subgraphs first because a cross-boundary edge attaches to + // the frame, which is large enough for the corner to be visible. + // + // Chosen at 12 by measurement, not taste: it is the smallest value that + // clears the corner on the `elk-edge-cases` corpus. 30 was tried and + // reorders layers, so this is not a free parameter — raising it changes + // more than clearance. + 'elk.spacing.portsSurrounding': PORTS_SURROUNDING, }, children: [], edges: [], @@ -846,6 +908,16 @@ function createRootElkGraph( Object.assign(graph.layoutOptions, rootLayoutOptions); } + // Hand-run experiments from `elkOptionCatalogue.ts`, last so an option + // switched on there wins over the preset, `config.elk.*` and the sweep. + // MUST all be commented out on `develop` — this is the production path. + Object.assign( + graph.layoutOptions, + PLACEMENT_OPTIONS, + EDGE_ROUTING_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES + ); + return graph; } diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 1dfa7499ca3..3afc433e008 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -131,23 +131,29 @@ export interface MermaidConfig { * They belong to different phases of the layout, so a preset is simply a * named triple rather than a mode with behaviour of its own. * - * `default` — network simplex layering, linear segments placement, greedy - * model order cycle breaking. Keeps chains of nodes aligned. + * `default` — network simplex layering and placement with depth-first + * cycle breaking at the top level, and Brandes-Koepf placement inside + * subgraphs. Depth-first breaking gives shorter back edges on graphs + * that loop. * * `legacy` — what shipped before presets existed: Brandes-Koepf placement, * which straightens long edges at the cost of that alignment, with ELK's * own greedy cycle breaking. Reproduces the rendering of earlier * versions rather than the defaults their schema advertised. * - * `depthFirst` — as `default`, but breaks cycles depth first, which tends - * to give shorter back edges on graphs that have many of them. + * `modelOrder` — as `default`, but breaks cycles by greedy model order, + * which disturbs declaration order least at the cost of longer back + * edges. This is the combination `default` named previously. + * + * `depthFirst` — a name for what `default` already is, for diagrams that + * would rather say depth-first than rely on the default. * * Setting `layeringStrategy`, `nodePlacementStrategy` or * `cycleBreakingStrategy` explicitly overrides the preset for that one * option; the rest of the preset still applies. * */ - preset?: 'default' | 'legacy' | 'depthFirst'; + preset?: 'default' | 'legacy' | 'modelOrder' | 'depthFirst'; /** * Straightens an edge that leaves or enters a node with a tiny step. * diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index c45088011a2..e4e137fa4c3 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -153,16 +153,22 @@ properties: They belong to different phases of the layout, so a preset is simply a named triple rather than a mode with behaviour of its own. - `default` — network simplex layering, linear segments placement, greedy - model order cycle breaking. Keeps chains of nodes aligned. + `default` — network simplex layering and placement with depth-first + cycle breaking at the top level, and Brandes-Koepf placement inside + subgraphs. Depth-first breaking gives shorter back edges on graphs + that loop. `legacy` — what shipped before presets existed: Brandes-Koepf placement, which straightens long edges at the cost of that alignment, with ELK's own greedy cycle breaking. Reproduces the rendering of earlier versions rather than the defaults their schema advertised. - `depthFirst` — as `default`, but breaks cycles depth first, which tends - to give shorter back edges on graphs that have many of them. + `modelOrder` — as `default`, but breaks cycles by greedy model order, + which disturbs declaration order least at the cost of longer back + edges. This is the combination `default` named previously. + + `depthFirst` — a name for what `default` already is, for diagrams that + would rather say depth-first than rely on the default. Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one @@ -171,6 +177,7 @@ properties: enum: - default - legacy + - modelOrder - depthFirst default: default straightenEdges: From cd8b6302797a77b2c91f298c69888706d05795c0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:45:16 +0000 Subject: [PATCH 21/31] [autofix.ci] apply automated fixes --- .../setup/mermaid/interfaces/MermaidConfig.md | 114 +++++++++--------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index f86ed6af34d..fbb1c3703cc 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/config.type.ts:66](https://github.com/mermaid- > `optional` **agentflow**: `AgentflowDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:316](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L316) +Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322) --- @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/config.type.ts:316](https://github.com/mermaid > `optional` **altFontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L254) +Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L260) --- @@ -34,7 +34,7 @@ Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid > `optional` **architecture**: `ArchitectureDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L328) +Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334) --- @@ -42,7 +42,7 @@ Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid > `optional` **arrowMarkerAbsolute**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:273](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L273) +Defined in: [packages/mermaid/src/config.type.ts:279](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L279) Controls whether or arrow markers in html code are absolute paths or anchors. This matters if you are using base tag settings. @@ -53,7 +53,7 @@ This matters if you are using base tag settings. > `optional` **block**: `BlockDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336) +Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342) --- @@ -61,7 +61,7 @@ Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid > `optional` **c4**: `C4DiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333) +Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339) --- @@ -69,7 +69,7 @@ Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid > `optional` **class**: `ClassDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L321) +Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L327) --- @@ -77,7 +77,7 @@ Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid > `optional` **cynefin**: `CynefinDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343) +Defined in: [packages/mermaid/src/config.type.ts:349](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L349) --- @@ -85,7 +85,7 @@ Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid > `optional` **darkMode**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L238) +Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L244) --- @@ -93,7 +93,7 @@ Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid > `optional` **deterministicIds**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:306](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L306) +Defined in: [packages/mermaid/src/config.type.ts:312](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L312) This option controls if the generated ids of nodes in the SVG are generated randomly or based on a seed. @@ -109,7 +109,7 @@ should not change unless content is changed. > `optional` **deterministicIDSeed**: `string` -Defined in: [packages/mermaid/src/config.type.ts:313](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L313) +Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L319) This option is the optional seed for deterministic ids. If set to `undefined` but deterministicIds is `true`, a simple number iterator is used. @@ -121,7 +121,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345) +Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L351) --- @@ -215,23 +215,29 @@ Elk specific option affecting how nodes are placed. #### preset? -> `optional` **preset**: `"legacy"` | `"default"` | `"depthFirst"` +> `optional` **preset**: `"legacy"` | `"default"` | `"modelOrder"` | `"depthFirst"` Named combination of the three options that decide where nodes end up: layering strategy, node placement strategy and cycle breaking strategy. They belong to different phases of the layout, so a preset is simply a named triple rather than a mode with behaviour of its own. -`default` — network simplex layering, linear segments placement, greedy -model order cycle breaking. Keeps chains of nodes aligned. +`default` — network simplex layering and placement with depth-first +cycle breaking at the top level, and Brandes-Koepf placement inside +subgraphs. Depth-first breaking gives shorter back edges on graphs +that loop. `legacy` — what shipped before presets existed: Brandes-Koepf placement, which straightens long edges at the cost of that alignment, with ELK's own greedy cycle breaking. Reproduces the rendering of earlier versions rather than the defaults their schema advertised. -`depthFirst` — as `default`, but breaks cycles depth first, which tends -to give shorter back edges on graphs that have many of them. +`modelOrder` — as `default`, but breaks cycles by greedy model order, +which disturbs declaration order least at the cost of longer back +edges. This is the combination `default` named previously. + +`depthFirst` — a name for what `default` already is, for diagrams that +would rather say depth-first than rely on the default. Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one @@ -262,7 +268,7 @@ port, or would introduce a crossing. > `optional` **er**: `ErDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L323) +Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329) --- @@ -270,7 +276,7 @@ Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid > `optional` **eventmodeling**: `EventModelingDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337) +Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343) --- @@ -278,7 +284,7 @@ Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid > `optional` **flowchart**: `FlowchartDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L314) +Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L320) --- @@ -286,7 +292,7 @@ Defined in: [packages/mermaid/src/config.type.ts:314](https://github.com/mermaid > `optional` **fontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:253](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L253) +Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L259) Specifies the font to be used in the rendered diagrams. Can be any possible CSS `font-family`. @@ -298,7 +304,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L347) +Defined in: [packages/mermaid/src/config.type.ts:353](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L353) --- @@ -306,7 +312,7 @@ Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid > `optional` **forceLegacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:295](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L295) +Defined in: [packages/mermaid/src/config.type.ts:301](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L301) This option forces Mermaid to rely on KaTeX's own stylesheet for rendering MathML. Due to differences between OS fonts and browser's MathML implementation, this option is recommended if consistent rendering is important. @@ -318,7 +324,7 @@ If set to true, ignores legacyMathML. > `optional` **gantt**: `GanttDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:318](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L318) +Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L324) --- @@ -326,7 +332,7 @@ Defined in: [packages/mermaid/src/config.type.ts:318](https://github.com/mermaid > `optional` **gitGraph**: `GitGraphDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332) +Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338) --- @@ -344,7 +350,7 @@ Defines the seed to be used when using handDrawn look. This is important for the > `optional` **htmlLabels**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) +Defined in: [packages/mermaid/src/config.type.ts:252](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L252) Flag for setting whether or not a html tag should be used for rendering labels on nodes and edges. **Note:** Diagram-specific `htmlLabels` settings (e.g., `flowchart.htmlLabels`) are deprecated. @@ -357,7 +363,7 @@ over any diagram-specific settings. > `optional` **ishikawa**: `IshikawaDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L330) +Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336) --- @@ -365,7 +371,7 @@ Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid > `optional` **journey**: `JourneyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L319) +Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325) --- @@ -373,7 +379,7 @@ Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid > `optional` **kanban**: `KanbanDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L331) +Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337) --- @@ -391,7 +397,7 @@ Defines which layout algorithm to use for rendering the diagram. > `optional` **legacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:288](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L288) +Defined in: [packages/mermaid/src/config.type.ts:294](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L294) This option specifies if Mermaid can expect the dependent to include KaTeX stylesheets for browsers without their own MathML implementation. If this option is disabled and MathML is not supported, the math @@ -404,7 +410,7 @@ fall back to legacy rendering for KaTeX. > `optional` **logLevel**: `0` | `2` | `1` | `"trace"` | `"debug"` | `"info"` | `"warn"` | `"error"` | `"fatal"` | `3` | `4` | `5` -Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L259) +Defined in: [packages/mermaid/src/config.type.ts:265](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L265) This option decides the amount of logging to be used by mermaid. @@ -424,7 +430,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L348) +Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L354) --- @@ -452,7 +458,7 @@ The maximum allowed size of the users text diagram > `optional` **mindmap**: `MindmapDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329) +Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335) --- @@ -460,7 +466,7 @@ Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid > `optional` **packet**: `PacketDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335) +Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341) --- @@ -468,7 +474,7 @@ Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid > `optional` **pie**: `PieDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L324) +Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L330) --- @@ -476,7 +482,7 @@ Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid > `optional` **quadrantChart**: `QuadrantChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325) +Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L331) --- @@ -484,7 +490,7 @@ Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid > `optional` **radar**: `RadarDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339) +Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345) --- @@ -492,7 +498,7 @@ Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid > `optional` **railroad**: `RailroadDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344) +Defined in: [packages/mermaid/src/config.type.ts:350](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L350) --- @@ -500,7 +506,7 @@ Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid > `optional` **requirement**: `RequirementDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L327) +Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333) --- @@ -508,7 +514,7 @@ Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid > `optional` **sankey**: `SankeyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334) +Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340) --- @@ -516,7 +522,7 @@ Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid > `optional` **secure**: `string`\[] -Defined in: [packages/mermaid/src/config.type.ts:280](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L280) +Defined in: [packages/mermaid/src/config.type.ts:286](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L286) This option controls which `currentConfig` keys are considered secure and can only be changed via call to `mermaid.initialize`. @@ -528,7 +534,7 @@ This prevents malicious graph directives from overriding a site's default securi > `optional` **securityLevel**: `"strict"` | `"loose"` | `"antiscript"` | `"sandbox"` -Defined in: [packages/mermaid/src/config.type.ts:263](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L263) +Defined in: [packages/mermaid/src/config.type.ts:269](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L269) Level of trust for parsed diagram @@ -538,7 +544,7 @@ Level of trust for parsed diagram > `optional` **sequence**: `SequenceDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:317](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L317) +Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L323) --- @@ -546,7 +552,7 @@ Defined in: [packages/mermaid/src/config.type.ts:317](https://github.com/mermaid > `optional` **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:267](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L267) +Defined in: [packages/mermaid/src/config.type.ts:273](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L273) Dictates whether mermaid starts on Page load @@ -556,7 +562,7 @@ Dictates whether mermaid starts on Page load > `optional` **state**: `StateDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322) +Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L328) --- @@ -564,7 +570,7 @@ Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L354) +Defined in: [packages/mermaid/src/config.type.ts:360](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L360) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -575,7 +581,7 @@ This is useful when you want to control how to handle syntax errors in your appl > `optional` **swimlane**: `SwimlaneDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:315](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L315) +Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L321) --- @@ -610,7 +616,7 @@ Defined in: [packages/mermaid/src/config.type.ts:85](https://github.com/mermaid- > `optional` **timeline**: `TimelineDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L320) +Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L326) --- @@ -618,7 +624,7 @@ Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid > `optional` **treeView**: `TreeViewDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338) +Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344) --- @@ -626,7 +632,7 @@ Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid > `optional` **usecase**: `UsecaseDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340) +Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L346) --- @@ -634,7 +640,7 @@ Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid > `optional` **venn**: `VennDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341) +Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L347) --- @@ -642,7 +648,7 @@ Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid > `optional` **wardley-beta**: `WardleyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342) +Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L348) --- @@ -650,7 +656,7 @@ Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L346) +Defined in: [packages/mermaid/src/config.type.ts:352](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L352) --- @@ -658,4 +664,4 @@ Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid > `optional` **xyChart**: `XYChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L326) +Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332) From 3f050155058a0a6789a32580bd9b7f84d4597d45 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 13:43:48 +0200 Subject: [PATCH 22/31] fix(er, requirement): stop emitting invalid CSS for the colour-theme palettes Both stylesheets generate one rule per palette slot, looping to THEME_COLOR_LIMIT and indexing the palette by the loop counter. Two things go wrong with that, and neither raises anything -- the browser discards the invalid declaration, so the only symptom is a shape rendering unstyled. Indexing raw means a palette with fewer entries than THEME_COLOR_LIMIT emits `stroke: undefined` for the overflow slots. Both now wrap at the palette length. `requirement` also emitted `fill: ;` -- a property with no value -- whenever there was no background palette. That is not hypothetical: `redux-dark-color` ships a border palette and an empty background palette so that it colours outlines only, which means every requirement diagram under that theme carries twelve invalid declarations today. The declaration is now omitted instead. `paletteCssGeneration.spec.ts` asserts the shape of the generated CSS rather than the colours, so it holds for any palette: no `undefined` values, no empty declarations, every slot resolving to a real colour when the palette is shorter than the limit, and no `fill` at all when there is no fill palette. Confirmed it fails four ways against the current code and passes with the fix. Split out of the redux-color default-theme stack: these are independent of that change and can land on their own. --- .changeset/er-requirement-palette-css.md | 11 +++ .../common/paletteCssGeneration.spec.ts | 82 +++++++++++++++++++ packages/mermaid/src/diagrams/er/styles.ts | 13 ++- .../src/diagrams/requirement/styles.js | 18 +++- 4 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 .changeset/er-requirement-palette-css.md create mode 100644 packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts diff --git a/.changeset/er-requirement-palette-css.md b/.changeset/er-requirement-palette-css.md new file mode 100644 index 00000000000..4bc1c2fad20 --- /dev/null +++ b/.changeset/er-requirement-palette-css.md @@ -0,0 +1,11 @@ +--- +'mermaid': patch +--- + +fix(er, requirement): stop the ER and requirement stylesheets emitting invalid CSS for the colour themes. + +Both generate one rule per palette slot, looping to `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Indexing raw means a palette with fewer entries than that limit emits `stroke: undefined` for the overflow slots; both now wrap at the palette length. + +`requirement` also emitted `fill: ;` — a property with no value, which is invalid — whenever there was no background palette. That is the live case for `redux-dark-color`, which ships a border palette and no background palette so that it colours outlines only. The declaration is now omitted instead. + +Neither raises an error: the browser discards the invalid declaration, so the only symptom is a shape rendering unstyled. diff --git a/packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts new file mode 100644 index 00000000000..7d9ba4912ee --- /dev/null +++ b/packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts @@ -0,0 +1,82 @@ +/** + * The ER and requirement stylesheets generate one CSS rule per palette slot, looping to + * `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Two things went wrong + * with that, and neither produces an error anywhere — they emit CSS the browser quietly + * discards, so the only symptom is a shape rendering unstyled: + * + * 1. Indexing raw means a palette shorter than `THEME_COLOR_LIMIT` yields + * `stroke: undefined` for the overflow slots. + * 2. `requirement` emitted `fill: ;` — an empty value, invalid CSS — whenever there was + * no background palette. `redux-dark-color` is the live case: it ships a border + * palette and no background palette, colouring outlines only. + * + * These assertions are about the shape of the generated CSS rather than the colours, so + * they hold for any palette. + */ +import { describe, expect, it, afterEach } from 'vitest'; +import * as configApi from '../../config.js'; +import themes from '../../themes/index.js'; +import erStyles from '../er/styles.js'; +import requirementStyles from '../requirement/styles.js'; + +const STYLESHEETS = { + er: erStyles, + requirement: requirementStyles, +} as const; + +const COLOUR_THEMES = ['redux-color', 'redux-dark-color'] as const; + +/** + * `requirement/styles.js` reads the theme and palette from `getConfig()` while + * `er/styles.ts` reads them off its options argument, so drive both. + */ +const render = ( + name: keyof typeof STYLESHEETS, + themeName: string, + overrides: Record = {} +) => { + const themeVariables = themes[themeName as keyof typeof themes].getThemeVariables({}); + const merged = { ...(themeVariables as unknown as Record), ...overrides }; + configApi.reset(); + configApi.setSiteConfig({ theme: themeName as 'redux-color', themeVariables: merged }); + return STYLESHEETS[name]({ ...merged, theme: themeName, look: 'classic' } as never); +}; + +afterEach(() => { + configApi.reset(); +}); + +describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s stylesheet', (name) => { + it.each(COLOUR_THEMES)('emits no undefined values for %s', (themeName) => { + expect(render(name, themeName)).not.toContain('undefined'); + }); + + it.each(COLOUR_THEMES)('emits no empty declarations for %s', (themeName) => { + // `fill: ;` and friends — a property with no value at all. + expect(render(name, themeName)).not.toMatch(/[\w-]+:\s*;/); + }); + + it('survives a palette shorter than THEME_COLOR_LIMIT', () => { + const css = render(name, 'redux-color', { + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }); + expect(css).not.toContain('undefined'); + // Every slot still gets a rule, and every rule names one of the two colours. Scoped to + // the palette blocks -- the rest of the stylesheet has its own `stroke:` declarations. + const paletteBlocks = [...css.matchAll(/\[data-color-id="color-\d+"][^{]*{([^}]*)}/g)].map( + (m) => m[1] + ); + const strokes = paletteBlocks.flatMap((block) => + [...block.matchAll(/stroke:\s*([^;]+);/g)].map((m) => m[1].trim()) + ); + expect(strokes.length).toBeGreaterThan(2); + expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); + }); + + it('omits the fill declaration when there is no background palette', () => { + const css = render(name, 'redux-color', { bkgColorArray: [] }); + expect(css).not.toMatch(/fill:\s*;/); + expect(css).toContain('stroke:'); + }); +}); diff --git a/packages/mermaid/src/diagrams/er/styles.ts b/packages/mermaid/src/diagrams/er/styles.ts index c9edd0647b2..460ec176db6 100644 --- a/packages/mermaid/src/diagrams/er/styles.ts +++ b/packages/mermaid/src/diagrams/er/styles.ts @@ -23,16 +23,21 @@ const genColor: DiagramStylesProvider = (options) => { let sections = ''; for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + // Wrap at the palette length rather than indexing raw. The loop runs to + // THEME_COLOR_LIMIT, so a palette with fewer entries than that emits + // `stroke: undefined` for the overflow slots. + const borderColor = borderColorArray[i % borderColorArray.length]; + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; sections += ` [data-look="${look}"][data-color-id="color-${i}"].node path { - stroke: ${borderColorArray[i]}; - ${hasBkgColors ? `fill: ${bkgColorArray[i]};` : ''} + stroke: ${borderColor}; + ${fill} } [data-look="${look}"][data-color-id="color-${i}"].node rect { - stroke: ${borderColorArray[i]}; - ${hasBkgColors ? `fill: ${bkgColorArray[i]};` : ''} + stroke: ${borderColor}; + ${fill} } `; } diff --git a/packages/mermaid/src/diagrams/requirement/styles.js b/packages/mermaid/src/diagrams/requirement/styles.js index 60154211faa..d5328336635 100644 --- a/packages/mermaid/src/diagrams/requirement/styles.js +++ b/packages/mermaid/src/diagrams/requirement/styles.js @@ -10,17 +10,27 @@ const genColor = (options) => { } let sections = ''; + const hasBkgColors = bkgColorArray?.length > 0; + for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + // Omit the declaration when there is no fill palette, rather than emitting `fill: ;` + // -- an empty value is invalid CSS. `redux-dark-color` is the live case: it ships a + // border palette and no background palette, colouring outlines only. + // + // Wrap at the palette length for the same reason as `er/styles.ts`: the loop runs to + // THEME_COLOR_LIMIT, so a shorter palette would emit `stroke: undefined`. + const borderColor = borderColorArray[i % borderColorArray.length]; + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; sections += ` [data-look="${look}"][data-color-id="color-${i}"].node path { - stroke: ${borderColorArray[i]}; - fill: ${bkgColorArray?.length ? bkgColorArray[i] : ''}; + stroke: ${borderColor}; + ${fill} } [data-look="${look}"][data-color-id="color-${i}"].node rect { - stroke: ${borderColorArray[i]}; - fill: ${bkgColorArray?.length ? bkgColorArray[i] : ''}; + stroke: ${borderColor}; + ${fill} } `; } From 79c0d78c91756b02160baff5e380ce939d2b1d23 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 13:58:57 +0200 Subject: [PATCH 23/31] docs(changeset): shorten the theme-superset changeset Review feedback: still too verbose at three paragraphs. Cut to one, matching the 6-14 line norm of the existing changesets on develop. The detail lives in the commit messages and the PR description. --- .changeset/redux-color-theme-superset.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.changeset/redux-color-theme-superset.md b/.changeset/redux-color-theme-superset.md index 5f2ab75af7a..b2650f436e8 100644 --- a/.changeset/redux-color-theme-superset.md +++ b/.changeset/redux-color-theme-superset.md @@ -2,8 +2,4 @@ 'mermaid': patch --- -fix(themes): `redux-color` and `redux-dark-color` now define every theme variable their base themes define, and pie, gantt and user-journey draw from the theme palette instead of rendering monochrome. - -The colour themes were forked from `redux` / `redux-dark` by copy-paste and had drifted: some variables were missing (`stateEdgeLabelBackground`, `requirementEdgeLabelBackground`), others silently re-derived from the grey `primaryColor` (`primaryBorderColor`, `clusterBkg`, `clusterBorder`, `altBackground`, `compositeTitleBackground`). - -Separately, pie, gantt and user-journey read flat `pieN` / `fillTypeN` / `sectionBkgColor` variables rather than the `cScale` array, so they never picked up the palette — `pie3` resolved to pure white in `redux-color` and near-black in `redux-dark-color`, and gantt had no visible section banding at all. They now use the theme's categorical scale. +fix(themes): `redux-color` and `redux-dark-color` now define every variable their base themes define, instead of silently falling back to untuned values for `primaryBorderColor`, `clusterBkg`, `clusterBorder`, `altBackground`, `compositeTitleBackground` and the state and requirement edge-label backgrounds. Pie, gantt and user-journey also read the theme palette rather than a single pale tint, so pie slices are distinguishable and gantt sections are visibly banded. From e89cc6f2ef4083d930d4434075bbdfb2dcdb4380 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 14:10:36 +0200 Subject: [PATCH 24/31] fix(er): guard an empty border palette, and tighten the palette CSS spec Review found that the wrap this PR introduces has its own hole. `i % borderColorArray.length` is `i % 0` for an empty palette, which is NaN, and `[][NaN]` is undefined -- so ER would emit `stroke: undefined` across all twelve slots, the exact symptom the wrap was added to remove. `requirement/styles.js` already bailed on an empty border palette; ER gated on the theme name only and now checks both. Reachable through a `themeVariables` override. The spec missed it because the short-palette case supplies two entries and the empty case only emptied `bkgColorArray`. It now covers an empty border palette explicitly, asserting that no palette rules are emitted at all -- the correct outcome when there is no palette to render. Confirmed it fails when the new guard alone is removed. `toContain('stroke:')` was close to vacuous: it ran against the whole stylesheet, where `.entityBox` and `.reqBox` already carry `stroke:`, so it passed even if genColor returned nothing. Now scoped to the palette rule bodies, and confirmed it fails when genColor is short-circuited to ''. Moved the spec from `diagrams/common/` up to `diagrams/`, a sibling of the diagram folders. It imports two diagram stylesheets, and `common/` is imported by every diagram type, so a cross-diagram spec in there implies a dependency that does not exist. Dropped the casts in the helper: `theme` is typed as `MermaidConfig['theme']` rather than asserted to one member of the union, and the options argument uses a declared `PaletteOptions` shape instead of `as never`, which had been disabling checking for the whole call. --- .../common/paletteCssGeneration.spec.ts | 82 ------------ packages/mermaid/src/diagrams/er/styles.ts | 7 +- .../src/diagrams/paletteCssGeneration.spec.ts | 122 ++++++++++++++++++ 3 files changed, 128 insertions(+), 83 deletions(-) delete mode 100644 packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts create mode 100644 packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts diff --git a/packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts deleted file mode 100644 index 7d9ba4912ee..00000000000 --- a/packages/mermaid/src/diagrams/common/paletteCssGeneration.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * The ER and requirement stylesheets generate one CSS rule per palette slot, looping to - * `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Two things went wrong - * with that, and neither produces an error anywhere — they emit CSS the browser quietly - * discards, so the only symptom is a shape rendering unstyled: - * - * 1. Indexing raw means a palette shorter than `THEME_COLOR_LIMIT` yields - * `stroke: undefined` for the overflow slots. - * 2. `requirement` emitted `fill: ;` — an empty value, invalid CSS — whenever there was - * no background palette. `redux-dark-color` is the live case: it ships a border - * palette and no background palette, colouring outlines only. - * - * These assertions are about the shape of the generated CSS rather than the colours, so - * they hold for any palette. - */ -import { describe, expect, it, afterEach } from 'vitest'; -import * as configApi from '../../config.js'; -import themes from '../../themes/index.js'; -import erStyles from '../er/styles.js'; -import requirementStyles from '../requirement/styles.js'; - -const STYLESHEETS = { - er: erStyles, - requirement: requirementStyles, -} as const; - -const COLOUR_THEMES = ['redux-color', 'redux-dark-color'] as const; - -/** - * `requirement/styles.js` reads the theme and palette from `getConfig()` while - * `er/styles.ts` reads them off its options argument, so drive both. - */ -const render = ( - name: keyof typeof STYLESHEETS, - themeName: string, - overrides: Record = {} -) => { - const themeVariables = themes[themeName as keyof typeof themes].getThemeVariables({}); - const merged = { ...(themeVariables as unknown as Record), ...overrides }; - configApi.reset(); - configApi.setSiteConfig({ theme: themeName as 'redux-color', themeVariables: merged }); - return STYLESHEETS[name]({ ...merged, theme: themeName, look: 'classic' } as never); -}; - -afterEach(() => { - configApi.reset(); -}); - -describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s stylesheet', (name) => { - it.each(COLOUR_THEMES)('emits no undefined values for %s', (themeName) => { - expect(render(name, themeName)).not.toContain('undefined'); - }); - - it.each(COLOUR_THEMES)('emits no empty declarations for %s', (themeName) => { - // `fill: ;` and friends — a property with no value at all. - expect(render(name, themeName)).not.toMatch(/[\w-]+:\s*;/); - }); - - it('survives a palette shorter than THEME_COLOR_LIMIT', () => { - const css = render(name, 'redux-color', { - borderColorArray: ['#ff0000', '#00ff00'], - bkgColorArray: ['#ffeeee', '#eeffee'], - }); - expect(css).not.toContain('undefined'); - // Every slot still gets a rule, and every rule names one of the two colours. Scoped to - // the palette blocks -- the rest of the stylesheet has its own `stroke:` declarations. - const paletteBlocks = [...css.matchAll(/\[data-color-id="color-\d+"][^{]*{([^}]*)}/g)].map( - (m) => m[1] - ); - const strokes = paletteBlocks.flatMap((block) => - [...block.matchAll(/stroke:\s*([^;]+);/g)].map((m) => m[1].trim()) - ); - expect(strokes.length).toBeGreaterThan(2); - expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); - }); - - it('omits the fill declaration when there is no background palette', () => { - const css = render(name, 'redux-color', { bkgColorArray: [] }); - expect(css).not.toMatch(/fill:\s*;/); - expect(css).toContain('stroke:'); - }); -}); diff --git a/packages/mermaid/src/diagrams/er/styles.ts b/packages/mermaid/src/diagrams/er/styles.ts index 460ec176db6..546cd20dc9c 100644 --- a/packages/mermaid/src/diagrams/er/styles.ts +++ b/packages/mermaid/src/diagrams/er/styles.ts @@ -16,7 +16,12 @@ const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); const genColor: DiagramStylesProvider = (options) => { const { theme, look, bkgColorArray, borderColorArray } = options; - if (!COLOR_THEMES.has(theme)) { + // Bail on an empty border palette as well as a non-colour theme. Without the second + // check `i % borderColorArray.length` is `i % 0` -> NaN, and `[][NaN]` is `undefined`, + // so every slot would emit `stroke: undefined` -- the exact symptom this guard exists to + // prevent. `requirement/styles.js` already gated on the palette; this brings ER into + // line. Reachable through a `themeVariables` override, not just in theory. + if (!COLOR_THEMES.has(theme) || !borderColorArray?.length) { return ''; } const hasBkgColors = bkgColorArray?.length > 0; diff --git a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts new file mode 100644 index 00000000000..641e383ac71 --- /dev/null +++ b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts @@ -0,0 +1,122 @@ +/** + * The ER and requirement stylesheets generate one CSS rule per palette slot, looping to + * `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Three things went + * wrong with that, and none produces an error anywhere — they emit CSS the browser quietly + * discards, so the only symptom is a shape rendering unstyled. No screenshot test can + * catch it: a diagram renders identically with or without a discarded declaration. + * + * 1. Indexing raw means a palette shorter than `THEME_COLOR_LIMIT` yields + * `stroke: undefined` for the overflow slots. + * 2. Wrapping with `i % length` fixes that but reintroduces it for an *empty* palette: + * `i % 0` is `NaN` and `[][NaN]` is `undefined`. Both files bail early instead. + * 3. `requirement` emitted `fill: ;` — an empty value, invalid CSS — whenever there was + * no background palette. `redux-dark-color` is the live case: it ships a border + * palette and no background palette, colouring outlines only. + * + * These assertions are about the shape of the generated CSS rather than the colours, so + * they keep holding when the palettes are retuned. + * + * Deliberately a sibling of the diagram folders rather than inside `common/`: it imports + * two diagram stylesheets, and `common/` is imported by every diagram type, so a + * cross-diagram spec in there would imply a dependency that does not exist. + */ +import { describe, expect, it, afterEach } from 'vitest'; +import * as configApi from '../config.js'; +import type { MermaidConfig } from '../config.type.js'; +import themes from '../themes/index.js'; +import erStyles from './er/styles.js'; +import requirementStyles from './requirement/styles.js'; + +const STYLESHEETS = { + er: erStyles, + requirement: requirementStyles, +} as const; + +const COLOUR_THEMES = [ + 'redux-color', + 'redux-dark-color', +] as const satisfies MermaidConfig['theme'][]; + +/** The subset of theme variables these stylesheets read. */ +interface PaletteOptions { + theme: MermaidConfig['theme']; + look: MermaidConfig['look']; + THEME_COLOR_LIMIT: number; + borderColorArray?: string[]; + bkgColorArray?: string[]; + [key: string]: unknown; +} + +/** + * Drives both channels: `er/styles.ts` reads the theme and palette off its options + * argument, while `requirement/styles.js` reads them from `getConfig()`. + */ +const render = ( + name: keyof typeof STYLESHEETS, + theme: MermaidConfig['theme'], + overrides: Partial = {} +): string => { + const themeVariables = themes[theme as keyof typeof themes].getThemeVariables({}); + const options: PaletteOptions = { + ...(themeVariables as unknown as Record), + theme, + look: 'classic', + THEME_COLOR_LIMIT: 12, + ...overrides, + }; + configApi.reset(); + configApi.setSiteConfig({ theme, look: 'classic', themeVariables: options }); + return STYLESHEETS[name](options); +}; + +/** The declaration bodies of the palette rules, ignoring the rest of the stylesheet. */ +const paletteBlocks = (css: string): string[] => + [...css.matchAll(/\[data-color-id="color-\d+"][^{]*{([^}]*)}/g)].map((m) => m[1]); + +afterEach(() => { + configApi.reset(); +}); + +describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s stylesheet', (name) => { + it.each(COLOUR_THEMES)('emits no undefined values for %s', (theme) => { + expect(render(name, theme)).not.toContain('undefined'); + }); + + it.each(COLOUR_THEMES)('emits no empty declarations for %s', (theme) => { + // `fill: ;` and friends — a property with no value at all. + expect(render(name, theme)).not.toMatch(/[\w-]+:\s*;/); + }); + + it('survives a palette shorter than THEME_COLOR_LIMIT', () => { + const css = render(name, 'redux-color', { + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }); + expect(css).not.toContain('undefined'); + // Every slot still gets a rule, and every rule names one of the two colours. Scoped to + // the palette blocks — the rest of the stylesheet has its own `stroke:` declarations. + const strokes = paletteBlocks(css).flatMap((block) => + [...block.matchAll(/stroke:\s*([^;]+);/g)].map((m) => m[1].trim()) + ); + expect(strokes.length).toBeGreaterThan(2); + expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); + }); + + it('emits nothing at all for an empty border palette', () => { + // `i % 0` is NaN, so wrapping the index is not enough on its own — the guard has to + // bail before the loop. Emitting no palette rules is the correct outcome: there is no + // palette to render. + const css = render(name, 'redux-color', { borderColorArray: [] }); + expect(css).not.toContain('undefined'); + expect(paletteBlocks(css)).toEqual([]); + }); + + it('omits the fill declaration when there is no background palette', () => { + const css = render(name, 'redux-color', { bkgColorArray: [] }); + expect(css).not.toMatch(/fill:\s*;/); + // Scoped to a palette rule: the base stylesheet carries its own `stroke:` declarations, + // so an unscoped check would pass even if genColor returned nothing. + expect(paletteBlocks(css).length).toBeGreaterThan(0); + expect(paletteBlocks(css).every((block) => block.includes('stroke:'))).toBe(true); + }); +}); From aa1f543010d8e33a61a69c262c9856f6f9cfcfec Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 14:16:45 +0200 Subject: [PATCH 25/31] fix(timeline): guard and wrap the palette index Pulled forward from #8148 on request, so the fix lands on develop rather than waiting on the default-theme stack. Timeline was the last stylesheet still indexing `borderColorArray[i]` raw in a loop to THEME_COLOR_LIMIT, with no wrap and no palette guard. Written without the shared colour-theme gate, which does not exist on develop: the gate becomes `theme?.includes('color') && options.borderColorArray?.length > 0` and the index wraps at the palette length. #8148 supersedes this with `isColorTheme()` from `diagrams/common/colorThemeGate.js`, so expect a conflict in this function when that stack rebases -- resolution is to take #8148's version. The spec splits into two passes. The invalid-CSS assertions -- no `undefined`, no empty declarations, both for a short palette and an empty one -- apply to all three stylesheets. The slot-shaped assertions stay scoped to ER and requirement, since timeline colours `.section-N` classes directly rather than emitting `[data-color-id]` rules. Confirmed both timeline assertions fail against develop's version and pass with the fix. Verified: unit suite green at 5770, and the timeline / ER / requirement e2e specs green at 395. --- .changeset/er-requirement-palette-css.md | 6 +-- .../src/diagrams/paletteCssGeneration.spec.ts | 42 +++++++++++++++---- .../mermaid/src/diagrams/timeline/styles.js | 15 +++++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/.changeset/er-requirement-palette-css.md b/.changeset/er-requirement-palette-css.md index 4bc1c2fad20..5d881d03f26 100644 --- a/.changeset/er-requirement-palette-css.md +++ b/.changeset/er-requirement-palette-css.md @@ -2,10 +2,10 @@ 'mermaid': patch --- -fix(er, requirement): stop the ER and requirement stylesheets emitting invalid CSS for the colour themes. +fix(er, requirement, timeline): stop the ER, requirement and timeline stylesheets emitting invalid CSS for the colour themes. -Both generate one rule per palette slot, looping to `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Indexing raw means a palette with fewer entries than that limit emits `stroke: undefined` for the overflow slots; both now wrap at the palette length. +All three generate one rule per palette slot, looping to `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Indexing raw means a palette with fewer entries than that limit emits `stroke: undefined` for the overflow slots; all three now wrap at the palette length, and bail before the loop for an empty palette — wrapping alone is not enough there, because `i % 0` is `NaN` and `[][NaN]` is `undefined`. `requirement` also emitted `fill: ;` — a property with no value, which is invalid — whenever there was no background palette. That is the live case for `redux-dark-color`, which ships a border palette and no background palette so that it colours outlines only. The declaration is now omitted instead. -Neither raises an error: the browser discards the invalid declaration, so the only symptom is a shape rendering unstyled. +None of these raises an error: the browser discards the invalid declaration, so the only symptom is a shape rendering unstyled. diff --git a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts index 641e383ac71..83e4f6fb733 100644 --- a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts +++ b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts @@ -26,12 +26,25 @@ import type { MermaidConfig } from '../config.type.js'; import themes from '../themes/index.js'; import erStyles from './er/styles.js'; import requirementStyles from './requirement/styles.js'; +import timelineStyles from './timeline/styles.js'; const STYLESHEETS = { er: erStyles, requirement: requirementStyles, + timeline: timelineStyles, } as const; +type Stylesheet = keyof typeof STYLESHEETS; + +const ALL_STYLESHEETS = Object.keys(STYLESHEETS) as Stylesheet[]; + +/** + * Which of them emit `[data-color-id]` slot rules. `timeline` is palette-aware but + * colours `.section-N` classes directly, so the slot-shaped assertions do not apply to + * it — only the invalid-CSS ones do. + */ +const SLOT_STYLESHEETS = ['er', 'requirement'] as const satisfies readonly Stylesheet[]; + const COLOUR_THEMES = [ 'redux-color', 'redux-dark-color', @@ -77,7 +90,7 @@ afterEach(() => { configApi.reset(); }); -describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s stylesheet', (name) => { +describe.each(ALL_STYLESHEETS)('%s stylesheet', (name) => { it.each(COLOUR_THEMES)('emits no undefined values for %s', (theme) => { expect(render(name, theme)).not.toContain('undefined'); }); @@ -87,12 +100,27 @@ describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s styl expect(render(name, theme)).not.toMatch(/[\w-]+:\s*;/); }); - it('survives a palette shorter than THEME_COLOR_LIMIT', () => { + it('emits no undefined values for a palette shorter than THEME_COLOR_LIMIT', () => { const css = render(name, 'redux-color', { borderColorArray: ['#ff0000', '#00ff00'], bkgColorArray: ['#ffeeee', '#eeffee'], }); expect(css).not.toContain('undefined'); + }); + + it('emits no undefined values for an empty border palette', () => { + // `i % 0` is NaN, so wrapping the index is not enough on its own — the guard has to + // bail before the loop. + expect(render(name, 'redux-color', { borderColorArray: [] })).not.toContain('undefined'); + }); +}); + +describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => { + it('resolves every slot from a palette shorter than THEME_COLOR_LIMIT', () => { + const css = render(name, 'redux-color', { + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }); // Every slot still gets a rule, and every rule names one of the two colours. Scoped to // the palette blocks — the rest of the stylesheet has its own `stroke:` declarations. const strokes = paletteBlocks(css).flatMap((block) => @@ -102,13 +130,9 @@ describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s styl expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); }); - it('emits nothing at all for an empty border palette', () => { - // `i % 0` is NaN, so wrapping the index is not enough on its own — the guard has to - // bail before the loop. Emitting no palette rules is the correct outcome: there is no - // palette to render. - const css = render(name, 'redux-color', { borderColorArray: [] }); - expect(css).not.toContain('undefined'); - expect(paletteBlocks(css)).toEqual([]); + it('emits no slot rules at all for an empty border palette', () => { + // Emitting nothing is the correct outcome: there is no palette to render. + expect(paletteBlocks(render(name, 'redux-color', { borderColorArray: [] }))).toEqual([]); }); it('omits the fill declaration when there is no background palette', () => { diff --git a/packages/mermaid/src/diagrams/timeline/styles.js b/packages/mermaid/src/diagrams/timeline/styles.js index be5a36c90bb..10b075b7cc1 100644 --- a/packages/mermaid/src/diagrams/timeline/styles.js +++ b/packages/mermaid/src/diagrams/timeline/styles.js @@ -6,7 +6,11 @@ const genReduxSections = (options) => { //Required to read the active theme at render time, // since options alone does not expose the theme name needed to switch between redux and classic section generators. const isDarkTheme = theme?.includes('dark'); - const isColorTheme = theme?.includes('color'); + // Gate on the palette actually being present, not just on the theme name. `theme` comes + // from global config while `options` is passed in, so the two can disagree -- and with an + // empty palette `borderColorArray[i]` is `undefined`, emitting `stroke: undefined`. Same + // guard as `er/styles.ts` and `requirement/styles.js`. + const isColorTheme = theme?.includes('color') && options.borderColorArray?.length > 0; const rawSvgId = options.svgId?.replace(/^#/, '') ?? ''; const scopedDropShadow = rawSvgId ? `url(#${rawSvgId}-drop-shadow)` @@ -16,8 +20,13 @@ const genReduxSections = (options) => { for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { const sw = `${17 - 3 * i}`; - const color = isColorTheme ? options.borderColorArray[i] : options.mainBkg; - const stroke = isColorTheme ? options.borderColorArray[i] : options.nodeBorder; + // Wrap at the palette length rather than indexing raw: the loop runs to + // THEME_COLOR_LIMIT, so a shorter palette would leave the overflow slots undefined. + const slot = isColorTheme + ? options.borderColorArray[i % options.borderColorArray.length] + : undefined; + const color = slot ?? options.mainBkg; + const stroke = slot ?? options.nodeBorder; sections += ` .section-${i - 1} rect, From a3a92bac6a62c3df4359e17d555cd20e14503cde Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 14:25:18 +0200 Subject: [PATCH 26/31] fix(sequence): index each actor colour palette by its own length All eleven actor drawers read `bkgColorArray[actorCount % borderColorArray.length]` -- one palette indexed by the other's length. The expression was copy-pasted across every actor type, stroke and fill, 22 sites in all. It is currently harmless, which is why it survived: both shipped palettes have twelve entries, so the wrong length happens to give the right answer. It breaks as soon as they differ -- a background palette shorter than the border palette leaves the overflow actors resolving to `undefined`, and because `selection.style(name, undefined)` takes d3's *remove* path (verified in d3-selection 3.0.0), the inline fill silently disappears for some actors and not others. That remove path is also load-bearing, so the fix keeps it. `redux-dark-color` ships a border palette and an *empty* background palette precisely so actors are outlined but not filled; a helper that substituted a fallback colour there would change how every sequence diagram renders under that theme. `paletteColor` returns `undefined` for an absent or empty palette for exactly that reason. Verified as a no-op for the shipped themes rather than assumed: the spec replays the old expression verbatim for 24 indices against both colour themes and asserts the new helper agrees at every one. That is what makes `patch` honest. The last two assertions read the module's own source. The helper tests cannot see a call site that goes back to the old expression, and that is the failure mode with history here -- it was copied eleven times before anyone noticed, so a new actor type copied from an existing one is the obvious way for it to return. Confirmed both fail when a single call site is reverted. --- .changeset/sequence-palette-indexing.md | 9 ++ .../diagrams/sequence/palettePicking.spec.ts | 115 ++++++++++++++++++ .../mermaid/src/diagrams/sequence/svgDraw.js | 61 ++++++---- 3 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 .changeset/sequence-palette-indexing.md create mode 100644 packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts diff --git a/.changeset/sequence-palette-indexing.md b/.changeset/sequence-palette-indexing.md new file mode 100644 index 00000000000..8ebaba0fb28 --- /dev/null +++ b/.changeset/sequence-palette-indexing.md @@ -0,0 +1,9 @@ +--- +'mermaid': patch +--- + +fix(sequence): index each actor colour palette by its own length. + +Every actor-drawing call site read `bkgColorArray[actorCount % borderColorArray.length]` — one palette indexed by the other's length. Both shipped palettes have twelve entries, so this is currently harmless; it goes wrong as soon as they differ, because the overflow actors resolve to `undefined` and d3 strips the inline fill for some actors and not others. + +Both palettes now cycle within their own length, via a shared helper. An absent or empty palette still yields `undefined` rather than a substitute colour, which is what `redux-dark-color` relies on: it ships a border palette and an empty background palette so actors are outlined but not filled. diff --git a/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts new file mode 100644 index 00000000000..e5c7aeb427c --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts @@ -0,0 +1,115 @@ +/** + * Sequence colours each actor from the theme palette. Every call site used to index + * `bkgColorArray` by `borderColorArray.length` — one array by the other's length. + * + * That is invisible while both palettes ship twelve entries, which is why it survived: it + * is wrong only when they differ. A background palette shorter than the border palette + * leaves the overflow actors resolving to `undefined`, and because + * `selection.style(name, undefined)` takes d3's *remove* path, the inline fill silently + * disappears for some actors and not others. + * + * `redux-dark-color` depends on that remove path: it ships a border palette and an empty + * background palette so actors are outlined but not filled. So the fix has to keep + * returning `undefined` for an absent palette rather than substituting a colour — these + * assertions pin both halves. + */ +import { describe, expect, it } from 'vitest'; +import themes from '../../themes/index.js'; +import { paletteColor } from './svgDraw.js'; +// Vite's `?raw` gives the module's own source, so the guard below reads the real file +// without depending on the working directory. +// @ts-expect-error -- `?raw` is a Vite import suffix, not a declared module +import svgDrawSource from './svgDraw.js?raw'; + +describe('paletteColor', () => { + const palette = ['#a', '#b', '#c']; + + it('cycles within the palette it was given', () => { + expect([0, 1, 2, 3, 4, 5].map((i) => paletteColor(palette, i))).toEqual([ + '#a', + '#b', + '#c', + '#a', + '#b', + '#c', + ]); + }); + + it('returns undefined for an empty palette rather than a substitute colour', () => { + // `undefined` is what makes d3 remove the inline style and defer to the stylesheet. + expect(paletteColor([], 0)).toBeUndefined(); + expect(paletteColor(undefined, 3)).toBeUndefined(); + }); + + it('never runs off the end of a short palette', () => { + // The old code asked for index `i % 12` from a 3-entry array. + const asked = Array.from({ length: 12 }, (_, i) => paletteColor(palette, i)); + expect(asked).not.toContain(undefined); + expect(new Set(asked)).toEqual(new Set(palette)); + }); + + it('is independent of any other palette length', () => { + // The bug in one line: a 2-entry background palette indexed by a 12-entry border + // palette's length loses every actor past the second. + const short = ['#x', '#y']; + const oldWay = Array.from({ length: 12 }, (_, i) => short[i % 12]); + const newWay = Array.from({ length: 12 }, (_, i) => paletteColor(short, i)); + expect(oldWay.filter((c) => c === undefined)).toHaveLength(10); + expect(newWay.filter((c) => c === undefined)).toHaveLength(0); + }); +}); + +/** + * The shipped palettes must render exactly as before — this is a latent-bug fix, not a + * visual change, and `patch` is only honest if that holds. + */ +describe('shipped colour themes are unaffected', () => { + it.each(['redux-color', 'redux-dark-color'] as const)('%s', (name) => { + const variables = themes[name].getThemeVariables({}) as unknown as { + borderColorArray: string[]; + bkgColorArray: string[]; + }; + const { borderColorArray, bkgColorArray } = variables; + + for (let i = 0; i < 24; i++) { + // What the old expression produced, verbatim. + const oldStroke = borderColorArray[i % borderColorArray.length]; + const oldFill = bkgColorArray[i % borderColorArray.length]; + expect(paletteColor(borderColorArray, i)).toBe(oldStroke); + expect(paletteColor(bkgColorArray, i)).toBe(oldFill); + } + }); + + it('redux-dark-color still yields no fill, so the stylesheet keeps deciding', () => { + const { bkgColorArray } = themes['redux-dark-color'].getThemeVariables({}) as unknown as { + bkgColorArray: string[]; + }; + expect(bkgColorArray).toHaveLength(0); + expect(paletteColor(bkgColorArray, 0)).toBeUndefined(); + }); +}); + +/** + * The helper assertions above cannot see a call site that goes back to indexing one + * palette by the other's length — and that is the failure mode with history here: the + * expression was copy-pasted across eleven actor drawers before anyone noticed. A new actor + * type copied from an existing one is the obvious way for it to return, so guard the shape + * of the source rather than only the helper's behaviour. + */ +describe('no call site indexes one palette by another', () => { + const source: string = svgDrawSource; + + it('has no raw palette indexing left', () => { + const raw = [...source.matchAll(/(\w*ColorArray)\[[^\]]*?(\w*ColorArray)\.length]/g)].map( + (m) => m[0] + ); + expect(raw).toEqual([]); + }); + + it('routes every actor colour through paletteColor', () => { + // Both halves of each stroke/fill pair. + const calls = [...source.matchAll(/paletteColor\((\w+),\s*actorCount\)/g)].map((m) => m[1]); + expect(calls.length).toBeGreaterThanOrEqual(22); + expect(new Set(calls)).toEqual(new Set(['borderColorArray', 'bkgColorArray'])); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/svgDraw.js b/packages/mermaid/src/diagrams/sequence/svgDraw.js index 0876acd9e22..07974b2d3f8 100644 --- a/packages/mermaid/src/diagrams/sequence/svgDraw.js +++ b/packages/mermaid/src/diagrams/sequence/svgDraw.js @@ -16,6 +16,23 @@ const ACTOR_MAN_FIGURE_CLASS = 'actor-man'; /** Exact set of themes that use color arrays for actor styling */ const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); + +/** + * Pick a colour for the nth actor from a palette, cycling within that palette's own + * length. + * + * Every call site used to index `bkgColorArray` by `borderColorArray.length` -- one array + * by the other's length. That is invisible while both ship twelve entries, and wrong the + * moment they differ: a shorter background palette leaves the overflow actors with + * `undefined`, so d3 strips the inline fill for some actors and not others. + * + * `undefined` for an absent or empty palette is deliberate rather than a fallback colour. + * `selection.style(name, undefined)` takes d3's remove path, which lets the stylesheet + * decide -- and that is exactly what `redux-dark-color` relies on, shipping a border + * palette and an empty background palette so that actors are outlined but not filled. + */ +export const paletteColor = (palette, index) => + palette?.length ? palette[index % palette.length] : undefined; export const drawRect = function (elem, rectData) { const rectElement = svgDrawCommon.drawRect(elem, rectData); // Call getConfig() here (not at module level) so multi-diagram pages get fresh config @@ -405,8 +422,8 @@ const drawActorTypeParticipant = function (elem, actor, conf, isFooter, actorInd const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - rectElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - rectElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + rectElem.style('stroke', paletteColor(borderColorArray, actorCount)); + rectElem.style('fill', paletteColor(bkgColorArray, actorCount)); } if (look === 'neo') { rectElem.attr('filter', 'url(#drop-shadow)'); @@ -536,10 +553,10 @@ const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorInd const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - rectElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - rectElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); - stackedRect.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - stackedRect.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + rectElem.style('stroke', paletteColor(borderColorArray, actorCount)); + rectElem.style('fill', paletteColor(bkgColorArray, actorCount)); + stackedRect.style('stroke', paletteColor(borderColorArray, actorCount)); + stackedRect.style('fill', paletteColor(bkgColorArray, actorCount)); } if (actor.properties?.icon) { @@ -671,10 +688,10 @@ const drawActorTypeQueue = function (elem, actor, conf, isFooter, actorIndexMap) const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - cylinderGroup.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - cylinderGroup.style('fill', bkgColorArray[actorCount % borderColorArray.length]); - cylinderArc.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - cylinderArc.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + cylinderGroup.style('stroke', paletteColor(borderColorArray, actorCount)); + cylinderGroup.style('fill', paletteColor(bkgColorArray, actorCount)); + cylinderArc.style('stroke', paletteColor(borderColorArray, actorCount)); + cylinderArc.style('fill', paletteColor(bkgColorArray, actorCount)); } if (actor.properties?.icon) { @@ -794,8 +811,8 @@ const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, a const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } else { actElem.style('stroke', actorBorder); actElem.style('fill', actorBkg); @@ -876,8 +893,8 @@ const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } const bounds = actElem.node().getBBox(); @@ -1013,8 +1030,8 @@ const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexM } const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - cylinderGroup.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - cylinderGroup.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + cylinderGroup.style('stroke', paletteColor(borderColorArray, actorCount)); + cylinderGroup.style('fill', paletteColor(bkgColorArray, actorCount)); } else { cylinderGroup.style('stroke', actorBorder); } @@ -1121,8 +1138,8 @@ const drawActorTypeBoundary = function (elem, actor, conf, isFooter, actorIndexM const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } else { actElem.style('stroke', actorBorder); } @@ -1250,8 +1267,8 @@ const drawActorTypeActor = function (elem, actor, conf, isFooter, actorIndexMap) const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } else { actElem.style('stroke', actorBorder); } @@ -1372,8 +1389,8 @@ export const drawActivation = function ( ); const actorCount = resolvedActorIndexMap.get(actor) ?? 0; if (COLOR_THEMES.has(theme)) { - rectElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - rectElem.style('fill', bkgColorArray[actorCount % borderColorArray.length] ?? mainBkg); + rectElem.style('stroke', paletteColor(borderColorArray, actorCount)); + rectElem.style('fill', paletteColor(bkgColorArray, actorCount) ?? mainBkg); } }; From c1eabe6b39fd760d17761ff1faf8b3f23803590c Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 14:52:43 +0200 Subject: [PATCH 27/31] test(er, requirement, timeline): make the palette assertions able to fail The slot test bounded the stroke count with `toBeGreaterThan(2)`, which still passes when only two of the twelve slots are emitted -- the opposite of what the comment above it claimed. Count the palette blocks and strokes exactly instead, derived from THEME_COLOR_LIMIT rather than a literal. The timeline short-palette test only asserted the absence of `undefined`. But `slot ?? options.nodeBorder` means dropping the `% length` wrap sends the overflow slots to the classic fallback colour rather than leaving them undefined, so that assertion passed with or without the fix. Assert the wrapped sequence, which is what actually regresses: sections 3..12 silently losing their palette colours. Both new assertions were checked against a reverted fix and fail as intended. --- .../src/diagrams/paletteCssGeneration.spec.ts | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts index 83e4f6fb733..0e54ac66ecc 100644 --- a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts +++ b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts @@ -45,6 +45,9 @@ const ALL_STYLESHEETS = Object.keys(STYLESHEETS) as Stylesheet[]; */ const SLOT_STYLESHEETS = ['er', 'requirement'] as const satisfies readonly Stylesheet[]; +/** Matches `theme-base.js`; the palette rules are emitted one per slot up to this. */ +const THEME_COLOR_LIMIT = 12; + const COLOUR_THEMES = [ 'redux-color', 'redux-dark-color', @@ -74,7 +77,7 @@ const render = ( ...(themeVariables as unknown as Record), theme, look: 'classic', - THEME_COLOR_LIMIT: 12, + THEME_COLOR_LIMIT, ...overrides, }; configApi.reset(); @@ -86,6 +89,17 @@ const render = ( const paletteBlocks = (css: string): string[] => [...css.matchAll(/\[data-color-id="color-\d+"][^{]*{([^}]*)}/g)].map((m) => m[1]); +/** + * The declaration bodies of timeline's `.section-N` rules. Anchored on the ` rect,` that + * opens each selector list, so it skips `.section-root` and the `[data-look="neo"]` + * gradient variants. + */ +const sectionBlocks = (css: string): string[] => + [...css.matchAll(/\.section--?\d+ rect,[^{]*{([^}]*)}/g)].map((m) => m[1]); + +const strokesIn = (blocks: string[]): string[] => + blocks.flatMap((block) => [...block.matchAll(/stroke:\s*([^;]+);/g)].map((m) => m[1].trim())); + afterEach(() => { configApi.reset(); }); @@ -123,10 +137,13 @@ describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => { }); // Every slot still gets a rule, and every rule names one of the two colours. Scoped to // the palette blocks — the rest of the stylesheet has its own `stroke:` declarations. - const strokes = paletteBlocks(css).flatMap((block) => - [...block.matchAll(/stroke:\s*([^;]+);/g)].map((m) => m[1].trim()) - ); - expect(strokes.length).toBeGreaterThan(2); + // Counted exactly: a `greaterThan` bound would still pass if only a couple of slots + // were emitted, which is the regression this is here to catch. Each slot emits a + // `path` rule and a `rect` rule, hence twice the limit. + const blocks = paletteBlocks(css); + const strokes = strokesIn(blocks); + expect(blocks).toHaveLength(THEME_COLOR_LIMIT * 2); + expect(strokes).toHaveLength(THEME_COLOR_LIMIT * 2); expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); }); @@ -144,3 +161,33 @@ describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => { expect(paletteBlocks(css).every((block) => block.includes('stroke:'))).toBe(true); }); }); + +describe('timeline section rules', () => { + it('wraps the palette across every section rather than falling back', () => { + const borderColorArray = ['#ff0000', '#00ff00']; + const css = render('timeline', 'redux-color', { borderColorArray }); + const strokes = strokesIn(sectionBlocks(css)); + + // The assertion has to be the wrapped *sequence*, not just the absence of `undefined`. + // `slot ?? options.nodeBorder` means dropping the `% length` wrap sends the overflow + // slots to the classic fallback colour instead of leaving them undefined, so a + // `not.toContain('undefined')` check passes either way and catches nothing. What + // actually regresses is sections 3..12 silently losing their palette colours. + expect(strokes).toHaveLength(THEME_COLOR_LIMIT); + expect(strokes).toEqual( + Array.from( + { length: THEME_COLOR_LIMIT }, + (_, i) => borderColorArray[i % borderColorArray.length] + ) + ); + }); + + it('falls back to the classic colours for an empty border palette', () => { + const css = render('timeline', 'redux-color', { borderColorArray: [] }); + const strokes = strokesIn(sectionBlocks(css)); + expect(strokes).toHaveLength(THEME_COLOR_LIMIT); + expect(new Set(strokes)).not.toContain(undefined); + // No palette to cycle, so every section takes the classic border colour. + expect(new Set(strokes).size).toBe(1); + }); +}); From bb6d4cfc3d3a8c00addb62261ebb4731520d2f8c Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 15:28:45 +0200 Subject: [PATCH 28/31] fix(sequence): make the call-site guard pairing-aware Review found the guard blind to the mistake it exists to catch. It collected only the palette argument, so a swapped pair -- `stroke` fed from `bkgColorArray` and `fill` from `borderColorArray` -- produced the same set and passed. That is precisely what a copy-paste between two adjacent `.style()` lines produces. Reproduced the reviewer's finding before fixing it: swapping the pair at all eleven drawers left every one of the 167 sequence unit tests passing, the 9 new ones included. The guard now captures the property alongside the palette and pins both pairings, and with it in place that same swap fails -- and is the only failure across the suite, so the gap is closed rather than merely narrowed. `*?raw` is now declared in `src/type.d.ts` instead of suppressed per-file with `@ts-expect-error`. Confirmed `build:types` is clean with the suppression gone, so the declaration is doing the work rather than hiding a real error. The `22` in the count assertion is written as `2 * 11` -- two properties for each of the eleven drawers -- since `expected 21 to be greater than or equal to 22` gave no hint where the number came from. Documented why `drawActivation` keeps `?? mainBkg` when the actor drawers do not: an activation rect spans the lifeline it sits on, so it needs an opaque fill or the line shows through. That is a question of opacity rather than of palette, which is why it belongs at that call site and not in the helper. Changeset no longer contradicts itself -- it claimed both shipped palettes have twelve entries two paragraphs before noting that `redux-dark-color`'s background palette is empty. Scoped to `redux-color`. --- .changeset/sequence-palette-indexing.md | 2 +- .../diagrams/sequence/palettePicking.spec.ts | 19 ++++++++++++------- .../mermaid/src/diagrams/sequence/svgDraw.js | 5 +++++ packages/mermaid/src/type.d.ts | 10 ++++++++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.changeset/sequence-palette-indexing.md b/.changeset/sequence-palette-indexing.md index 8ebaba0fb28..dbbd68757bd 100644 --- a/.changeset/sequence-palette-indexing.md +++ b/.changeset/sequence-palette-indexing.md @@ -4,6 +4,6 @@ fix(sequence): index each actor colour palette by its own length. -Every actor-drawing call site read `bkgColorArray[actorCount % borderColorArray.length]` — one palette indexed by the other's length. Both shipped palettes have twelve entries, so this is currently harmless; it goes wrong as soon as they differ, because the overflow actors resolve to `undefined` and d3 strips the inline fill for some actors and not others. +Every actor-drawing call site read `bkgColorArray[actorCount % borderColorArray.length]` — one palette indexed by the other's length. Under `redux-color` both palettes have twelve entries, so the wrong length happens to give the right answer and nothing is visibly broken today. It goes wrong as soon as the two differ in length: the overflow actors resolve to `undefined`, and d3 then strips the inline fill for some actors and not others. Both palettes now cycle within their own length, via a shared helper. An absent or empty palette still yields `undefined` rather than a substitute colour, which is what `redux-dark-color` relies on: it ships a border palette and an empty background palette so actors are outlined but not filled. diff --git a/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts index e5c7aeb427c..9ff744c538d 100644 --- a/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts +++ b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts @@ -17,8 +17,7 @@ import { describe, expect, it } from 'vitest'; import themes from '../../themes/index.js'; import { paletteColor } from './svgDraw.js'; // Vite's `?raw` gives the module's own source, so the guard below reads the real file -// without depending on the working directory. -// @ts-expect-error -- `?raw` is a Vite import suffix, not a declared module +// without depending on the working directory. Declared in `src/type.d.ts`. import svgDrawSource from './svgDraw.js?raw'; describe('paletteColor', () => { @@ -106,10 +105,16 @@ describe('no call site indexes one palette by another', () => { expect(raw).toEqual([]); }); - it('routes every actor colour through paletteColor', () => { - // Both halves of each stroke/fill pair. - const calls = [...source.matchAll(/paletteColor\((\w+),\s*actorCount\)/g)].map((m) => m[1]); - expect(calls.length).toBeGreaterThanOrEqual(22); - expect(new Set(calls)).toEqual(new Set(['borderColorArray', 'bkgColorArray'])); + it('routes every actor colour through paletteColor, each to its own property', () => { + // Capture the property as well as the palette. Collecting only the palette argument + // would be blind to a swapped pair -- `stroke` fed from `bkgColorArray` and `fill` from + // `borderColorArray` -- which is exactly what a copy-paste between two adjacent + // `.style()` lines produces, and which no unit test here would otherwise notice. + const pairs = [ + ...source.matchAll(/\.style\(\s*'(stroke|fill)',\s*paletteColor\((\w+),\s*actorCount\)/g), + ].map((m) => `${m[1]}<-${m[2]}`); + // Two properties for each of the eleven actor drawers. + expect(pairs.length).toBeGreaterThanOrEqual(2 * 11); + expect(new Set(pairs)).toEqual(new Set(['stroke<-borderColorArray', 'fill<-bkgColorArray'])); }); }); diff --git a/packages/mermaid/src/diagrams/sequence/svgDraw.js b/packages/mermaid/src/diagrams/sequence/svgDraw.js index 07974b2d3f8..2ae3fa9b566 100644 --- a/packages/mermaid/src/diagrams/sequence/svgDraw.js +++ b/packages/mermaid/src/diagrams/sequence/svgDraw.js @@ -30,6 +30,11 @@ const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); * `selection.style(name, undefined)` takes d3's remove path, which lets the stylesheet * decide -- and that is exactly what `redux-dark-color` relies on, shipping a border * palette and an empty background palette so that actors are outlined but not filled. + * + * `drawActivation` is the one caller that does add `?? mainBkg`, and deliberately so: an + * activation rect spans the lifeline it sits on, so it needs an opaque fill or the line + * shows through it. That is a question of opacity rather than of palette, which is why it + * belongs at that call site and not in here. */ export const paletteColor = (palette, index) => palette?.length ? palette[index % palette.length] : undefined; diff --git a/packages/mermaid/src/type.d.ts b/packages/mermaid/src/type.d.ts index 71a50368d92..a107a69259f 100644 --- a/packages/mermaid/src/type.d.ts +++ b/packages/mermaid/src/type.d.ts @@ -9,3 +9,13 @@ declare var injected: { */ profiling: boolean; }; + +/** + * Vite's `?raw` suffix, which yields a module's own source as a string. Used by specs that + * assert the *shape* of a source file rather than its behaviour -- see + * `diagrams/sequence/palettePicking.spec.ts`. + */ +declare module '*?raw' { + const content: string; + export default content; +} From bb9e955a7cd5afe20c317270a6c891ccebc912e7 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 15:40:37 +0200 Subject: [PATCH 29/31] chore(changeset): trim the docs:build entry to its summary line The two explanatory paragraphs restated the commit message and the PR description. The published changelog only needs the one-line summary. --- .changeset/fix-docs-build-destructive-order.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.changeset/fix-docs-build-destructive-order.md b/.changeset/fix-docs-build-destructive-order.md index 86485a75b29..a1d817aac96 100644 --- a/.changeset/fix-docs-build-destructive-order.md +++ b/.changeset/fix-docs-build-destructive-order.md @@ -3,7 +3,3 @@ --- fix(docs): stop `docs:build` deleting the committed `docs/` directory when a later step fails. - -`docs:build` ran `rimraf ../../docs` as its first step, before `docs:code` (typedoc) and `docs:spellcheck`. A failure in either left the whole committed `docs/` tree deleted and never regenerated, handing the contributor ~150 staged deletions with no obvious cause — and a pre-commit hook that could not succeed, since `docs:build` is wired to any change under `src/docs/**`. - -The deletion is still needed so that pages whose source was removed do not linger, so it now runs immediately before the step that regenerates the directory, after the two steps that can realistically fail. From 92e06dc0a08902f4a951065f9142ad18d713b705 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 15:54:40 +0200 Subject: [PATCH 30/31] fix(themes): make the weak superset assertions discriminate, and correct the khroma note Review found three of the four chart-palette assertions still passing against develop, which makes them decoration rather than tests. Two are rewritten to measure the property that was actually broken; the spec now fails 10 of 14 against the merge base, up from 6. Gantt bands: `not.toBe` was satisfied by two strings that differ while compositing to nothing. gantt/styles.js paints bands at 20% opacity, so the assertion now composites each band over the canvas and requires a max-channel separation of 16. Recomputed both sides: develop separates by 4 in the light theme and 0 in the dark; the tuned bands separate by 35 in both. Journey fills: `new Set(fills).size === 8` passed against develop, because the old tints *were* eight distinct values -- four hues repeated in pairs. Measuring hue instead discriminates 4 against 8. Worth recording that the obvious metric does not: minimum pairwise channel separation is 7.5 on develop against 5 here, because Tailwind-50 shades are deliberately close in value and differ in hue. The improvement is hue diversity, so that is what the test measures. The contrast-sign assertion is left as-is. It was never broken, but it is a real invariant and the thing that stops someone folding the dark journey fills back onto `cScale`. On the khroma directive: the review is right that the comment was false, and wrong that no directive is needed. khroma does declare these members (`dist/methods/index.d.ts`), but `dist/index.d.ts` re-exports them through a bare `export * from './methods'`, and under this repo's `module: nodenext` that resolves as ESM, where a specifier with no file extension does not resolve -- so TS sees the module as having no exports and every member errors. Verified by probe: removing the directive gives three TS2305 errors from `pnpm build:types`, and a namespace import fails the same way. An isolated `tsc --moduleResolution bundler` does compile it clean, which is what makes it look unnecessary. So the directive stays, with a comment that describes the real cause, and as `@ts-expect-error` rather than `@ts-ignore` -- confirmed it reports TS2578 the moment it stops being needed, so it cannot go stale the way the old one did. Also: the light theme's altSectionBkgColor goes back to the base's 'white' verbatim. Restating it as `this.background` was the same colour with a different string, which forced the variable into the drift net's exemption list for a pair that does not diverge. Only the dark theme changes it now, and the exemption says so. And the state fixture comment no longer claims to exercise `stateEdgeLabelBackground`. Nothing reads it -- `state/styles.js` uses the unprefixed `edgeLabelBackground` -- so that clause could never fail. --- .../redux-color-chart-themes.spec.ts | 10 ++- .../themes/theme-redux-color-superset.spec.ts | 64 +++++++++++++++---- .../mermaid/src/themes/theme-redux-color.js | 12 ++-- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/e2e/rendering/redux-color-chart-themes.spec.ts b/e2e/rendering/redux-color-chart-themes.spec.ts index 50695156a1c..dc95b66000b 100644 --- a/e2e/rendering/redux-color-chart-themes.spec.ts +++ b/e2e/rendering/redux-color-chart-themes.spec.ts @@ -83,8 +83,14 @@ const journeyDiagram = ` /** * Composite states and transition labels, which is what exercises - * `compositeTitleBackground`, `altBackground` and `stateEdgeLabelBackground` — the three - * variables that were missing or untuned in the colour themes. + * `compositeTitleBackground` and `altBackground` — two of the variables that were untuned + * in the colour themes. + * + * `stateEdgeLabelBackground` is deliberately not in that list even though this change adds + * it: nothing reads it. `state/styles.js` uses the unprefixed `edgeLabelBackground`, and a + * grep finds the prefixed name only in the theme files themselves. It is added for + * superset parity with the base themes, which already carry it, and has no rendered + * effect — so no fixture can cover it. */ const stateDiagram = ` stateDiagram-v2 diff --git a/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts index c2b301a2969..f8475317377 100644 --- a/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts +++ b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts @@ -19,9 +19,18 @@ * listed here explicitly, so widening it is a deliberate edit to this file * rather than something a loose pattern lets through unnoticed. */ -// @ts-ignore TODO: incorrect types from khroma -- `isDark` exists at runtime but is -// missing from the shipped .d.ts, the same gap worked around in er/styles.ts. -import { isDark } from 'khroma'; +// khroma's `dist/index.d.ts` re-exports everything via a bare `export * from './methods'`. +// Under this repo's `module: nodenext` that path is resolved as ESM, where a relative +// specifier without a file extension does not resolve -- so TS sees the module as having no exports at all +// and every member of this import errors. The members do exist (khroma +// `dist/methods/index.d.ts` exports them) and the import works at runtime; an isolated +// `tsc --moduleResolution bundler` also compiles it clean, which is why this looks +// unnecessary until `pnpm build:types` runs. +// +// `@ts-expect-error` rather than `@ts-ignore` deliberately: if khroma ships resolvable +// types or the module setting changes, this fails and gets deleted instead of lingering. +// @ts-expect-error -- see above +import { hue, isDark, toRgba } from 'khroma'; import { describe, expect, it } from 'vitest'; import themes from './index.js'; @@ -44,12 +53,13 @@ const PALETTE_VARS = new Set([ // Gantt section banding. 'sectionBkgColor', 'sectionBkgColor2', - // The third gantt band. Both base themes set it to 'white', which is right on a white - // canvas -- at the 20% opacity gantt paints bands with, it composites to nothing, so - // every other band reads as absent. On the dark canvas the same literal composites to - // rgb(92,92,92), a grey brighter than either tuned hue, so half of every gantt's - // banding fought the other half. Both colour themes now use the canvas colour, which - // gives the intended "absent" band in either mode. + // The third gantt band, and an exemption the *dark* pair needs only. Both base themes + // set it to 'white', which is right on a white canvas: at the 20% opacity gantt paints + // bands with it composites to nothing, so every other band reads as absent. On the dark + // canvas the same literal composites to rgb(92,92,92) -- a grey brighter than either + // tuned hue, so half of every gantt's banding fought the other half -- and the dark + // theme therefore uses its canvas colour instead. The light theme keeps 'white' + // verbatim, so it does not diverge and this exemption goes unused for that pair. 'altSectionBkgColor', ]); @@ -113,6 +123,22 @@ describe.each(PAIRS)('%s -> %s', (baseName, colorName) => { * "12 distinct values" alone would have passed before this was fixed — the tints * *were* distinct, just indistinguishable. So assert real separation instead. */ +/** The opacity `gantt/styles.js` paints its `.section` bands at. */ +const GANTT_BAND_OPACITY = 0.2; + +const channels = (color: string): number[] => + (toRgba(color).match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + +/** Flatten a colour onto a background at the given alpha, as the browser would. */ +const compositeOver = (foreground: string, background: string, alpha: number): number[] => { + const fg = channels(foreground); + const bg = channels(background); + return fg.map((value, i) => Math.round(alpha * value + (1 - alpha) * bg[i])); +}; + +const maxChannelDelta = (a: number[], b: number[]): number => + Math.max(...a.map((value, i) => Math.abs(value - b[i]))); + describe.each(['redux-color', 'redux-dark-color'] as const)('%s chart palettes', (name) => { const vars = themes[name].getThemeVariables({}) as unknown as Record; @@ -122,14 +148,26 @@ describe.each(['redux-color', 'redux-dark-color'] as const)('%s chart palettes', expect(slices).toEqual(scale); }); - it('gives user-journey eight distinct task fills', () => { + it('gives user-journey a distinct hue per section', () => { + // Not "eight distinct values": the old tints were eight distinct values too -- four + // hues repeated in pairs -- so a `Set` of the strings passes against the broken code. + // One hue per section is the property that was actually missing. (In the dark theme + // the old fills were also all under 5% saturation, i.e. greyscale.) const fills = Array.from({ length: 8 }, (_, i) => vars[`fillType${i}`]); expect(fills.every((fill) => typeof fill === 'string' && fill.length > 0)).toBe(true); - expect(new Set(fills).size).toBe(8); + const hues = fills.map((fill) => Math.round(hue(fill))); + expect(new Set(hues).size).toBe(8); }); - it('bands gantt sections with two different colours', () => { - expect(vars.sectionBkgColor).not.toBe(vars.sectionBkgColor2); + it('bands gantt sections with two visibly different colours', () => { + // `gantt/styles.js` paints bands at 20% opacity, so what matters is how the two + // composite over the canvas -- not whether the source strings differ. On the previous + // code they differed as strings while compositing to a max-channel delta of 4 in the + // light theme and 0 in the dark one: no visible banding at all, which a `not.toBe` + // assertion accepts without complaint. The tuned bands land at 35 in both. + const band0 = compositeOver(vars.sectionBkgColor, vars.background, GANTT_BAND_OPACITY); + const band2 = compositeOver(vars.sectionBkgColor2, vars.background, GANTT_BAND_OPACITY); + expect(maxChannelDelta(band0, band2)).toBeGreaterThanOrEqual(16); }); /** diff --git a/packages/mermaid/src/themes/theme-redux-color.js b/packages/mermaid/src/themes/theme-redux-color.js index 4548395f281..12620ab3b71 100644 --- a/packages/mermaid/src/themes/theme-redux-color.js +++ b/packages/mermaid/src/themes/theme-redux-color.js @@ -231,13 +231,17 @@ class Theme { * all -- the `primaryColor` tints used before composited to nothing. * * Assigned here rather than in the gantt block above so the two visible bands come - * off the categorical scale and follow it if the palette changes. altSectionBkgColor - * stays the canvas colour so every other band reads as absent, which is the same - * alternation `default` uses. + * off the categorical scale and follow it if the palette changes. + * + * altSectionBkgColor keeps the base theme's 'white' verbatim rather than being + * restated as `this.background`. It is the same colour on this canvas, and every other + * band reading as absent is already the correct behaviour here -- so leaving the string + * untouched keeps this variable out of the drift net's exemption list for this pair. + * The dark theme is where 'white' is wrong and has to change. */ this.sectionBkgColor = this.sectionBkgColor || this.cScale0; this.sectionBkgColor2 = this.sectionBkgColor2 || this.cScale1; - this.altSectionBkgColor = this.altSectionBkgColor || this.background; + this.altSectionBkgColor = this.altSectionBkgColor || 'white'; // Setup the inverted color for the set for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { From 49a1e6d5aa8cc59ae2d492d843f670a9c72f5266 Mon Sep 17 00:00:00 2001 From: Ashish Jain Date: Fri, 28 Aug 2026 16:01:21 +0200 Subject: [PATCH 31/31] test(sequence): count each palette pairing rather than checking set membership Review found the guard passable by a half-converted file: `new Set(pairs)` drops duplicates and the total was a `>=`, so 21 `stroke<-borderColorArray` entries alongside a single `fill<-bkgColorArray` satisfied both assertions. Confirmed by reverting ten of the eleven fills to raw indexing -- the previous form accepted it. Now counts each side and asserts they stay balanced, since every drawer paints both. Reproduced the same half-conversion afterwards: it fails with `expected [...] to have a length of 1 but got 11`. Kept as a floor of eleven rather than the suggested exact count. An exact 22 would fail on a correctly-added twelfth actor drawer, and a test that cries wolf on a valid change teaches people to bump the number without reading it -- which is the reflex that let this bug reach eleven call sites in the first place. Balance catches the asymmetric case the exact count was aimed at, and the floor still catches drawers being dropped or left unconverted. --- .../src/diagrams/sequence/palettePicking.spec.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts index 9ff744c538d..26d8e03db89 100644 --- a/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts +++ b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts @@ -113,8 +113,17 @@ describe('no call site indexes one palette by another', () => { const pairs = [ ...source.matchAll(/\.style\(\s*'(stroke|fill)',\s*paletteColor\((\w+),\s*actorCount\)/g), ].map((m) => `${m[1]}<-${m[2]}`); - // Two properties for each of the eleven actor drawers. - expect(pairs.length).toBeGreaterThanOrEqual(2 * 11); + const strokes = pairs.filter((pair) => pair === 'stroke<-borderColorArray'); + const fills = pairs.filter((pair) => pair === 'fill<-bkgColorArray'); + + // Each pairing must be the right way round... expect(new Set(pairs)).toEqual(new Set(['stroke<-borderColorArray', 'fill<-bkgColorArray'])); + // ...and the two must stay balanced, because every drawer paints both. A set plus a + // total count cannot see a half-converted file -- 21 strokes and a single fill would + // satisfy both. Counting each side catches that. + expect(strokes).toHaveLength(fills.length); + // A floor rather than an exact 11, so adding a twelfth actor drawer does not fail a + // correct change; it still catches drawers being dropped or left unconverted. + expect(strokes.length).toBeGreaterThanOrEqual(11); }); });