From 375e4ca43deb3eafbbf1e648cae1aa13af8c7254 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Mon, 24 Aug 2026 16:04:27 +0200 Subject: [PATCH 1/5] docs: rewrite the diagram guide and add a layout maker's guide The "Adding a New Diagram/Chart" page documented three steps while the review checklist enforces around fifteen, and three of its instructions had gone stale: styles are no longer wired up by hand in a themes object, detection no longer lives in detectType.ts, and commonDb moved under diagrams/common. Rewrite it as a sequenced build with usecase as the reference implementation, and end with the checklist a reviewer applies, so contributors can self-review against the same bar. Add the Layout Maker's Guide next to it, covering the LayoutData contract, the createCommonLayoutRenderer stages, validateLayout and the 0-1000 score, DDLT fixtures and the sweep, and the cases that actually break layout engines. Both are listed under Contributing. --- docs/community/layout-makers-guide.md | 526 ++++++++++++++++++ docs/community/new-diagram.md | 287 +++++++--- .../mermaid/src/docs/.vitepress/config.ts | 1 + .../src/docs/community/layout-makers-guide.md | 520 +++++++++++++++++ .../mermaid/src/docs/community/new-diagram.md | 288 +++++++--- 5 files changed, 1497 insertions(+), 125 deletions(-) create mode 100644 docs/community/layout-makers-guide.md create mode 100644 packages/mermaid/src/docs/community/layout-makers-guide.md diff --git a/docs/community/layout-makers-guide.md b/docs/community/layout-makers-guide.md new file mode 100644 index 00000000000..596a950c382 --- /dev/null +++ b/docs/community/layout-makers-guide.md @@ -0,0 +1,526 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/community/layout-makers-guide.md](../../packages/mermaid/src/docs/community/layout-makers-guide.md). + +# The Layout Maker's Guide πŸ—ΊοΈ + +A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code. Your job is coordinates. + +This guide covers layouts that live inside the Mermaid package, next to `dagre` and the others. The last section explains how to ship the same code as a standalone npm package instead. + +Two layouts in the tree are worth reading alongside it. `dagre` is the default and the oldest. `swimlanes` is the newest, and it is the one that follows the conventions described here, so most of the examples point at it. + +## What a layout receives and what it must produce + +Every layout is handed the same structure, `LayoutData`, regardless of which diagram type produced it: + +```ts +interface LayoutData { + nodes: Node[]; + edges: Edge[]; + config: MermaidConfig; + diagramId?: string; +} +``` + +You must fill in these fields and nothing else: + +| Field | On | Meaning | +| --------------------------- | ---- | ------------------------------------------------------------------- | +| `node.x`, `node.y` | Node | Center of the node, not its top-left corner | +| `node.width`, `node.height` | Node | Already measured for leaf nodes; you set them for groups | +| `edge.points` | Edge | Polyline from source boundary to target boundary, at least 2 points | +| `edge.x`, `edge.y` | Edge | Anchor for the edge label, when the edge has one | + +`Node` and `Edge` carry a good deal more than this, all of it defined in `rendering-util/types.ts`. Read the types there rather than working from what a debugger happens to show you: shape, label geometry, styling, and port information all travel on the same objects, and most of it belongs to the renderer rather than to you. + +Two structural fields are your input, not your output. `node.isGroup` marks a subgraph container, and `node.parentId` names the group a node belongs to. Read them, never rewrite them. + +Sizes arrive already measured. The renderer inserts every node into the SVG, calls `getBBox()`, and writes the result back before your algorithm runs. That measurement is the only step that touches the DOM, which is what makes the rest testable. + +## The five stages + +Layouts are built by calling `createCommonLayoutRenderer` from `rendering-util/layout-algorithms/common/index.ts`. It gives you five hooks, runs them in order, and handles painting: + +```ts +export const render = createCommonLayoutRenderer({ + prepareLayout, // reshape LayoutData before measuring + measureLayout, // DOM: insert elements, read sizes (has a default) + runLayoutCore, // your algorithm, no DOM allowed + paintLayout, // escape hatch: take over painting entirely + afterPaint, // touch up after paths exist + paintOptions, // tweak the standard painter +}); +``` + +Only `runLayoutCore` is required. The swimlanes layout is a complete example in twenty lines: + +```ts +import { createCommonLayoutRenderer } from '../common/index.js'; +import { applySwimlaneLineJumps } from './adjustLayout.js'; +import { prepareLayoutForSwimlanes } from './helpers.js'; +import { createEdgeLabelNodes } from './edgeLabelNodes.js'; +import { runSwimlaneLayoutCore } from './layoutCore.js'; + +function prepareSwimlaneLayout(data4Layout: LayoutData): void { + prepareLayoutForSwimlanes(data4Layout); + + const transformedData = createEdgeLabelNodes(data4Layout); + data4Layout.nodes = transformedData.nodes; + data4Layout.edges = transformedData.edges; +} + +export const render = createCommonLayoutRenderer({ + prepareLayout: prepareSwimlaneLayout, + runLayoutCore: runSwimlaneLayoutCore, + afterPaint: applySwimlaneLineJumps, +}); +``` + +### Keep the core free of the DOM + +`runLayoutCore` must be a pure function of `LayoutData`. No `document`, no `getBBox`, no d3 selections. + +The reason is testing. Node sizes get measured in a browser once and saved to a file. A test then loads those sizes, hands them to `runLayoutCore`, and gets back the same coordinates the browser would have produced, without opening a browser at all. That only holds while the core stays free of the DOM. The moment it reaches for `document`, it can only run inside a real page, and any test around it is exercising a different code path than the one your users hit. + +So write the core as one exported function, and have both the browser and your tests call that same function. + +## A minimal layout + +The examples from here on build a layout called `grid`. No such layout ships with Mermaid. It stands in for whatever you are writing, and the code below is what you would write to create it. + +Put the algorithm in `packages/mermaid/src/rendering-util/layout-algorithms//`. This one arranges leaf nodes in a grid and connects them with straight lines: + +```ts +// layout-algorithms/grid/layoutCore.ts +import type { LayoutData } from '../../types.js'; + +const GAP = 60; + +/** DOM-free: positions come from measured sizes only. */ +export function runGridLayoutCore(data4Layout: LayoutData): void { + const leaves = data4Layout.nodes.filter((node) => !node.isGroup); + const columns = Math.ceil(Math.sqrt(leaves.length)); + const cell = Math.max(...leaves.map((n) => Math.max(n.width ?? 0, n.height ?? 0))) + GAP; + + leaves.forEach((node, i) => { + node.x = (i % columns) * cell; + node.y = Math.floor(i / columns) * cell; + }); + + const byId = new Map(data4Layout.nodes.map((node) => [node.id, node])); + for (const edge of data4Layout.edges) { + const from = byId.get(edge.start ?? ''); + const to = byId.get(edge.end ?? ''); + if (!from || !to) { + continue; + } + edge.points = [ + { x: from.x ?? 0, y: from.y ?? 0 }, + { x: to.x ?? 0, y: to.y ?? 0 }, + ]; + } +} +``` + +```ts +// layout-algorithms/grid/index.ts +import { createCommonLayoutRenderer } from '../common/index.js'; +import { runGridLayoutCore } from './layoutCore.js'; + +export const render = createCommonLayoutRenderer({ runLayoutCore: runGridLayoutCore }); +``` + +That renders. It is also wrong in most of the ways a layout can be wrong. + +## Registering the layout + +The layouts that ship with Mermaid are listed in `registerDefaultLayoutLoaders()` in `packages/mermaid/src/rendering-util/render.ts`. Add one entry for yours: + +```ts +registerLayoutLoaders([ + { name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js') }, + { name: 'swimlane', loader: async () => await import('./layout-algorithms/swimlanes/index.js') }, + // your new layout + { name: 'grid', loader: async () => await import('./layout-algorithms/grid/index.js') }, +]); +``` + +The loader is lazy, so the code only downloads when a diagram asks for it. `cose-bilkent` is registered the same way but wrapped in a check on `includeLargeFeatures`, which is how a layout stays out of the tiny build. Users then select the layout by the name you registered: + +```text +--- +config: + layout: grid +--- +flowchart TB + A --> B +``` + +## Groups, labels, and edges + +### Groups + +A group node carries `isGroup: true`, and its members carry `parentId` pointing at it. Groups nest. Your algorithm owns the group's `x`, `y`, `width`, and `height`, and the frame must enclose every descendant with room for the title. Groups also have an optional `groupTitleRect` describing the header band. Edges routed through that band collide with the title text. + +The standard painter draws a group as a cluster and everything else as a node. Override that with `paintOptions.isCluster` when your layout has its own idea of which nodes are containers. + +### Edge labels + +Labels need space reserved before positions are decided, otherwise they land on top of edges and nodes. The approach that works is to split each labelled edge into `start β†’ label β†’ end` around a temporary node, let the algorithm place that node like any other, then fold it back into an overlay label. Swimlanes does this in `createEdgeLabelNodes`, called from its `prepareLayout` hook so the dummy node is measured as real text along with everything else. + +Invent a third mechanism and your labels will not be checked by the validator, because the validator reads the label geometry this pattern produces. + +### Edge endpoints and markers + +Edge paths must start and end on the boundary of their nodes, not at the center and not floating in space. By default the painter recomputes the endpoint by intersecting the path with the node shape, which will quietly bend the last segment of a carefully routed edge. Layouts that route to exact ports should turn that off with `paintOptions.skipIntersect`. + +Arrowheads occupy roughly the last ten pixels of the final segment. A bend inside that stretch puts a corner underneath the arrowhead, and the validator calls it: the constant is `EPS_FINAL_APPROACH`, and it is 10. + +### Self-loops and parallel edges + +An edge with `start === end` has no direction to follow and needs its own route, usually a small rectangle off one side of the node. Parallel edges between the same pair need to be separated by hand, or they render as one line. Fixtures for both live in `e2e/platform/dev-diagrams/layout-tests/` as `self-loop.mmd`, `self-loop-2.mmd`, `self-loop-multi.mmd`, and `identical-edges.mmd`. + +## Validating the result + +`validateLayout` in `layout-algorithms/layout-utils/validateLayout.ts` is the shared judge of layout quality. It takes finished `LayoutData` and returns a verdict plus a score: + +```ts +import { validateLayout } from '../layout-utils/validateLayout.js'; + +const result = validateLayout(layout); +// result.ok β†’ boolean, false when any hard constraint is broken +// result.issues β†’ what broke, with node/edge ids and details +// result.score β†’ 0 to 1000; exactly 0 whenever ok is false +// result.breakdown β†’ crossings, per-edge bend penalties, point histogram +``` + +### Hard constraints + +`ok` is false if any issue is present, and the score drops to zero. These are the failures worth knowing about before you write your router: + +| Issue | What it means | +| --------------------------------------------------------------- | ----------------------------------------------------- | +| `node-overlap` | Two nodes occupy the same space | +| `edge-intersects-node`, `edge-intersects-obstacle` | A path crosses a node it does not connect to | +| `edge-intersects-group-title` | A path runs through a subgraph's title band | +| `edge-endpoint-detached-from-node`, `edge-endpoint-inside-node` | An endpoint misses the boundary | +| `edge-non-orthogonal` | A segment is neither horizontal nor vertical | +| `edge-missing-points` | Fewer than two points on an edge | +| `edge-shared-subpath`, `edge-parallel-segment-too-close` | Two edges overlap or run too close to tell apart | +| `edge-shared-attachment-point`, `edge-same-port-departure` | Two edges leave a node from the same spot | +| `edge-bend-near-endpoint`, `edge-bend-overlaps-arrowhead` | A corner sits under the arrowhead or against the node | +| `edge-border-hugging`, `node-border-hugging` | Geometry runs along a border instead of clear of it | +| `edge-label-overlaps-node`, `edge-label-overlaps-foreign-edge` | A label lands on top of something else | +| `edge-label-off-edge` | A label sits away from the edge it belongs to | + +The tolerances are named constants at the top of `validateLayout.ts`, and two of them explain most first-time surprises: `EPS_FINAL_APPROACH` is 10, the stretch near an endpoint where a bend is not allowed, and `EPS_SHARED_ATTACH` is 3, how close two edges may attach to the same node before they count as sharing a point. The tests assert relative ordering rather than exact magnitudes, so treat the numbers as current rather than fixed. + +Some checks assume orthogonal routing. A layout with curved or diagonal edges will trip `edge-non-orthogonal` on every edge, so either route orthogonally or work out which subset of the validator applies to you before treating its score as a target. + +### The score + +When `ok` is true the score starts at 1000 and comes down. Bends are counted per edge from the polyline point count, and crossings are counted once globally: + +| Polyline points | Bends | Penalty | +| --------------- | ----- | ------------------- | +| 2 | 0 | 0 | +| 3 | 1 | 0 | +| 4 | 2 | 5 | +| 5 | 3 | 12 | +| 6 | 4 | 30 | +| 7 or more | 5+ | 30 Γ— 2^(points βˆ’ 6) | + +Each crossing costs 3. The curve is deliberately steep at the top end: one seven-bend edge costs more than twenty crossings, because a path nobody can follow is worse than a tidy diagram with intersections. + +The score does not scale with diagram size. A small graph reaches 1000; a large one rarely will, so compare a fixture against its own history rather than against another fixture. + +`result.breakdown.edges` is sorted worst-first, which makes it the fastest way to find what is dragging a layout down. + +### One thing that looks useful and is not + +`layout-utils` also holds `scoreLayout`, which computes softer metrics: aspect ratio, average bends per edge, rank faithfulness, neighborhood preservation, straight-edge ratio. Sitting next to `validateLayout`, it looks like the quality half of a matched pair. + +It is not wired into anything. Nothing in the layout pipeline calls it, no fixture spec calls it, and its only caller is its own unit test. Its `symmetryScore` is an unfinished placeholder that always returns `NaN`. Treat `validateLayout` as the only judge, and leave `scoreLayout` alone unless you are deliberately picking up that unfinished work. + +A third level is planned and not built. `compareLayoutSnapshot()` would diff a layout's structure against a stored baseline to catch regressions that stay inside the validator's tolerances. There is no such function today, so nothing depends on it. + +## Testing with DDLT + +DOM-Decoupled Layout Testing runs your algorithm in Node against sizes captured once from a real browser. Tests come back in seconds and give the same answer every time, and because the browser and the tests call the same core function, a fix in one is a fix in the other. + +### Tests must run the browser's code path + +The model is `parse β†’ measure β†’ run layout β†’ paint`, and the third step has to be a single function. Tests swap out the measuring; the browser does it for real. What runs in between must be identical. + +That sounds obvious, and it is where this gets broken most often. A test-only layout entry point sitting alongside the browser's is a bug even when the two look like they do the same thing. This has bitten this repo more than once, and the shape is always the same. The browser entry wrapped the edge pipeline in a degeneracy check and a direction-violation check, with a fallback and a reroute branch hanging off them. The test backend called the pipeline directly and skipped all of that. Fixtures came back valid, the browser took the fallback, and the fallback drew a polyline straight through the interior of a node. The validator would have caught it. The test harness never saw it. + +So when you wire up a test backend, find the function the browser actually calls and trace it end to end. If it needs the DOM, lift its DOM-free body into a helper and call that helper from both sides rather than reimplementing the sequence. Never point the test at a primitive that the browser wraps in checks, fallbacks, mirror branches, or second passes: the test has to include all of it. + +There is a cheap way to prove the seam is real. Change the browser orchestration, add a fallback or switch a default, and the test result for the same fixture should move. If it does not, the test is running around your change. Fix the seam rather than relaxing the test. + +When a fixture passes in Node but looks broken on screen, run `validateLayout` on both, on the same fixture, and compare the issues. Different issues mean different pipelines. Put the two call chains side by side, look for pre-passes, the main call, and post-passes, and the first place they diverge is the seam. + +### Fixtures + +A fixture is a pair of files under `e2e/platform/dev-diagrams/layout-tests/`: + +``` +layout-tests/ + simple-graph.mmd ← real Mermaid source, parsed by the real parser + simple-graph.sizes.json ← node dimensions captured from a browser render + ddlt-manifest.json ← per-fixture overrides +``` + +The `.mmd` file goes through the actual parser, so fixtures exercise the same `LayoutData` a user's diagram produces. The `.sizes.json` file holds one entry per leaf node and one per edge label, plus freshness metadata: + +```json +{ + "metadata": { + "captureVersion": 1, + "sourceSha256": "…", + "capturedAt": "2026-02-09T10:00:00Z", + "capturedFrom": "theme=default&look=classic" + }, + "nodes": [{ "id": "A", "width": 62, "height": 39 }] +} +``` + +`sourceSha256` is a hash of the `.mmd` file. Edit the diagram without recapturing and the test fails with a stale-fixture error instead of silently laying out with wrong sizes. + +`ddlt-manifest.json` gives a fixture a profile, which decides the backend it runs through, and can mark it `allowLevel1Failure` when a known failure is tracked in its own dedicated spec. + +### Capturing sizes + +There is a button for this. Start the dev server with `pnpm dev`, open the explorer at `/dev/`, and pick your diagram out of the file tree, which is rooted at the same `dev-diagrams` folder the fixtures live in. Switch to the Code tab and click **Save sizes**, sitting next to Save. + +It does the whole job. Unsaved edits to the diagram are written first, the diagram re-renders with size capture switched on, and the measurements go to `.sizes.json` beside the `.mmd`. The hash and the capture version are filled in for you, so the fixture passes the freshness check the moment it lands. + +The button is only enabled for layouts that can produce capture data, and it explains itself through a tooltip when it is greyed out. + +If you need to capture from somewhere other than the explorer, the same machinery is reachable from the console: + +```js +window.mermaidCaptureSizes = true; +// render the diagram, then: +copy(JSON.stringify(window.mermaidLastCapturedSizes.sizes, null, 2)); +``` + +Written by hand this way, the metadata block is yours to fill in. The capture module is dynamically imported only when that flag is set, so it never reaches a production bundle either way. + +Recapture when the diagram source changes or when a shape's real dimensions change. Do not recapture to make a failing test pass. The fixture is the before-picture, and rewriting it erases the regression you were trying to catch. + +### Parse the diagram, do not hand-build the graph + +It is tempting to skip the parser and write the `LayoutData` for a test by hand. Resist it. Hand-built graphs drift from what the parser emits, usually in the identifiers, and once the ids differ the test is scoring a graph the browser never lays out. + +The ids follow rules worth knowing, because fixture entries are matched against them: + +| Entity | Id | +| ----------------- | ------------------------------------------------------------------ | +| Content node | The unquoted name from the diagram source, such as `A` or `E` | +| Unquoted subgraph | The subgraph text, kept as written | +| Quoted subgraph | `subGraph`, numbered by a counter that ticks on every subgraph | +| Edge | `L___`, the counter separating parallel edges | +| Edge label node | `edge-label---` | + +The harness does the parsing for you. `parseMmdFileToLayoutData` strips frontmatter and directives, runs the real detector and parser, and stamps the direction the way the flowchart renderer does. `parseApplySizesAndLayout` goes further and applies the captured sizes and runs a backend. Reach for those before writing your own. + +If you do drive the parser yourself, two calls come first: `addDiagrams()` to register the diagram types, and `preprocessDiagram()` to handle frontmatter before `Diagram.fromText()` sees it. Skipping either produces failures that look like parser bugs and are not. + +Whichever route you take, fail loudly when a fixture entry has no matching parser-produced node. A silent miss leaves a node at its default size, and the layout you then measure is not the layout anyone will see. + +### Writing a spec + +Load the fixture, run your backend, assert on the result: + +```ts +import { describe, it, expect, beforeAll } from 'vitest'; +import { loadDdltFixture } from '../ddlt/index.js'; +import { validateLayout } from '../layout-utils/validateLayout.js'; + +describe('swimlanes, 1-simple', () => { + let layout: LayoutData; + beforeAll(async () => { + layout = await loadDdltFixture('swimlanes/1-simple', { backendId: 'swimlanes' }); + }); + + it('produces a valid layout', () => { + const result = validateLayout(layout); + expect(result.issues.map((i) => i.type)).toEqual([]); + expect(result.score).toBeGreaterThan(900); + }); +}); +``` + +Pass `backendId` explicitly. The default is a backend that is not present on `develop`, so a call without it throws rather than silently doing something reasonable. Your own layout needs an entry in `ddlt/backends.ts` before a fixture can run through it. + +`ddlt/index.ts` also exports `baselineDdltSpec(name)`, a one-liner that asserts the universal invariants: finite coordinates, at least two points per edge, no segment through an unrelated node, endpoints on boundaries. It hardcodes the same absent default backend and has no callers, so read it for the invariants it checks rather than calling it as it stands. + +Three shapes of test cover most needs. A tiny inline geometry test, where you build a handful of nodes by hand and check one routing rule, is right for a unit of the algorithm. A fixture-backed test on realistic sizes is right for a regression you can point at. A full source-to-layout run through the parser is right for anything a user reported. Match the surrounding folder: `swimlanes/query-process.ddlt.spec.ts` is the fullest worked example in the tree, and `layout-utils/validateLayout.spec.ts` and `ddlt/aggregateValidate.spec.ts` show the smaller shapes. + +### The sweep + +`ddlt/layout-fixtures.ddlt.spec.ts` discovers fixture pairs, runs them, and asserts validity across the board. It also emits an aggregate report, the number that tells you whether a change helped overall rather than on the one diagram you were staring at: + +```bash +# The sweep, with the aggregate report +pnpm exec vitest run \ + packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts + +# Just the aggregate line +pnpm exec vitest run \ + packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts \ + 2>&1 | grep 'DDLT-AGG' + +# One fixture while iterating +pnpm exec vitest run -t "1-simple" \ + packages/mermaid/src/rendering-util/layout-algorithms/ddlt/ +``` + +The report gives `total`, `avg`, `min`, and `invalid`, then one row per fixture with its score and issue types. Read it as a work queue: the lowest row is where the next improvement is. `ORTHO_TEST_DEBUG=1` in front of the command turns the layout logger from `fatal` up to `debug`, which helps when a row fails for reasons the report does not explain. + +The sweep as written filters to the `swimlanes` profile and holds that profile's total against a floor. Adding a layout means adding its profile to the manifest and its own aggregate assertion, not assuming the existing one will pick your fixtures up. + +## The cases that break layouts + +A layout that handles a chain of boxes tells you almost nothing. The cases below are where engines actually fail, roughly in the order yours will fail them. Every one has a diagram in `layout-tests` already, so there is nothing to write before you can find out. + +Most of them are source only. The folder holds around 45 diagrams and about 15 have captured sizes, and the sweep discovers fixtures by looking for `.sizes.json` files and pairing each with its sibling `.mmd`. A diagram with no sizes file is not being tested by anything, however tricky it looks. Check before assuming a case is covered, and if the sizes are missing, open the diagram in the explorer and press Save sizes. That one click is the difference between a diagram sitting in a folder and a case the sweep will defend. + +### Self-loops + +An edge whose source and target are the same node has no direction to travel in, and code that computes a route from two distinct positions tends to produce a zero-length path, a division by zero, or a dot. The route has to be manufactured: a small loop off one side, clear of the node and of anything the node's other edges are doing. + +`self-loop.mmd` is the single-node case, and the only one of the three with sizes captured. `self-loop-2.mmd` and `self-loop-multi.mmd` are harsher, putting a self-loop on all four nodes of a cycle, so every loop competes for space with real edges already using those sides. + +### Subgraphs + +This is the long tail, and it is where most of the work is. A subgraph is a node that contains other nodes, so every edge endpoint now has two possible meanings and every frame is an obstacle that also has to move as its contents move. + +| Case | Fixture | +| ------------------------------------- | --------------------------------------------------------- | +| A subgraph standing on its own | `decoupled-subgraph.mmd` | +| Edge into a subgraph | `edge-to-subgraph.mmd` | +| Edge into a node inside a subgraph | `edge-to-node-in-subgraph.mmd` | +| Edge out of a subgraph | `edge-from-subgraph.mmd` | +| Edge from inside out to a plain node | `subgraph-variation.mmd`, `subgraph-variation-2.mmd` | +| Between two sibling subgraphs | `nested-sg-outgoing-2.mmd`, `nested-incoming.mmd` | +| Inside one subgraph to inside another | `nested-sg-outgoing-2.mmd`, `nested-subgraphs-2.mmd` | +| Nested subgraphs | `nested-subgraphs.mmd`, `nested-subgraphs-3.mmd` | +| Edges crossing several nesting levels | `nested-sb-edges-in-out.mmd`, `nested-outgoing-edges.mmd` | +| Subgraph titles competing for space | `subgraph-labels.mmd` and its two variants | + +Work down that list in order. Each row assumes the ones above it. Only the first two rows have captured sizes today, so everything below them is a diagram you can open in the browser rather than a test that will tell you when you break it. + +Two failures recur. An edge that ends on a subgraph should stop at the frame rather than diving through to a member, and an edge that ends on a member has to cross the frame without clipping the title band. The other is sizing: a frame has to enclose everything inside it including the labels, and it has to keep doing so after a later pass nudges a member. + +### Parallel edges + +Two edges between the same pair of nodes are one edge as far as most routing code is concerned, because both get the same endpoints and the same optimal path, so they land exactly on top of each other and the diagram silently loses information. They have to be separated deliberately. + +`identical-edges.mmd` is the minimal case. `multiple-edges.mmd` adds a reverse edge to the bundle, so the fix cannot just fan edges out by index and ignore direction. `identical-edges-in-subgraph.mmd` puts a bundle in each direction inside a frame, where the room to fan out is bounded. + +### Busy nodes + +A node with more than four edges cannot give each one its own side. Ports have to share sides, share sides in an order that does not cross, and stay far enough apart to be told apart. Engines that assign one edge per side degrade sharply here, usually into a knot right against the node. + +`edge-types.mmd` piles several edges onto a single node with a different arrow type on each. `Company.mmd` and `Company-simp.mmd` are the realistic version of the same problem, and both have sizes captured. + +### Combinations, and both directions + +These interact, and the combinations are worse than the parts. A self-loop on a busy node inside a nested subgraph exercises all four at once, which is why the larger fixtures are worth keeping even though a failure in one is harder to diagnose. `deploy-pipeline.mmd`, `life-choices.mmd`, and `project-sox2.mmd` are the closest thing here to diagrams a user would actually write. + +Run the ones that matter in `TB` and `LR` both. Layout code tends to grow an implicit assumption about which way the graph flows, and the second direction is where that assumption surfaces. + +## Before you invent something + +Graph drawing has a long research record, and most of what a layout engine needs has been studied for decades. Orthogonal routing, compaction, port and side constraints, layered pipelines, crossing minimisation: none of these are new problems, and reading up on one is usually faster than deriving a heuristic and discovering its failure modes one fixture at a time. + +The vocabulary gap is worth knowing about, because it makes searching harder than it needs to be. What this codebase calls a jog, the literature calls a bend. A port window is a pin or a side constraint. A rail is a track or a channel. A group is a compound vertex. + +When you knowingly diverge from what the established approach recommends, write down why in the pull request, along with how you checked that the divergence works. + +## Watching it in the browser while the tests run + +The sweep tells you a score dropped. It does not tell you the diagram now looks like a plate of spaghetti. Keep a browser open next to the test run. + +```bash +pnpm dev +``` + +Do not assume the address. The port is derived from the path of the checkout, so every worktree and every clone gets its own and you can run several dev servers side by side without them fighting over 9000. The server prints its URL as it starts, before the build output scrolls past. `MERMAID_DEV_PORT` pins it if you want a fixed one. + +Open `/dev/` and you get the explorer: the fixture tree on one side, the diagram on the other, a code tab for editing the source, and a layout picker for comparing your algorithm against the others on the same input. It reloads when you change the source, so an edit to your algorithm redraws the diagram without you touching the browser. + +For a diagram that is not in the fixture tree, copy the standalone page template instead: + +```bash +cp demos/dev/example.html demos/dev/grid.html +``` + +That lands at `/dev/grid.html` on the same server. Put the diagram in the page and name your layout in the frontmatter: + +```html +
+---
+config:
+  layout: grid
+---
+flowchart TB
+  A --> B
+  B --> C
+
+``` + +A workflow that holds up over a long session: + +1. Run the sweep in one terminal, filtered to the fixture you are working on. +2. Keep that same fixture open in the browser. +3. Make one change and watch both. The score says whether it helped, and the picture says whether the score was measuring the right thing. +4. Before committing, run the full sweep and confirm the aggregate did not drop. + +The two disagree more often than you would expect, and the disagreement is informative. A score that improves while the diagram gets worse means the validator is blind to something, and that gap is worth writing down. + +Use the browser to check what the tests cannot see: text that overflows its shape, arrowheads pointing the wrong way, subgraph frames cutting through labels. + +## Shipping as a separate package + +An external layout uses the same `render` signature and the same `createCommonLayoutRenderer`. Instead of editing the built-in registry, export a loader array and let the consumer register it: + +```ts +import type { LayoutLoaderDefinition } from 'mermaid'; + +const loader = async () => await import('./render.js'); + +const layouts: LayoutLoaderDefinition[] = [{ name: 'grid', loader, algorithm: 'grid.compact' }]; + +export default layouts; +``` + +```js +import mermaid from 'mermaid'; +import layouts from 'my-mermaid-layout'; + +mermaid.registerLayoutLoaders(layouts); +``` + +The `algorithm` field is passed back to your renderer through `options`, which lets one package register several named variants that share an implementation. `packages/mermaid-layout-elk` does exactly this. + +Package it separately when the layout pulls in a large dependency. Everything else belongs in the main package, where it gets covered by the fixture sweep. + +## Checklist + +- [ ] `runLayoutCore` is one exported function with no DOM access +- [ ] The browser and the tests call that same function +- [ ] Group frames enclose their members and leave the title band clear +- [ ] Edge labels reserve space before positions are decided +- [ ] Edge endpoints land on node boundaries +- [ ] Self-loops and parallel edges have routes +- [ ] `validateLayout` returns `ok: true` on your fixtures +- [ ] Fixtures exist, with captured sizes, for the cases the algorithm was written to handle +- [ ] Your backend is registered in `ddlt/backends.ts` and your profile in `ddlt-manifest.json` +- [ ] The sweep passes and the aggregate score has not dropped +- [ ] `.mmd` fixtures under `e2e/diagrams/` cover the layout visually, since layout changes are rendering changes diff --git a/docs/community/new-diagram.md b/docs/community/new-diagram.md index 2c50d2c6ea5..023e2f98d43 100644 --- a/docs/community/new-diagram.md +++ b/docs/community/new-diagram.md @@ -6,12 +6,54 @@ # Adding a New Diagram/Chart πŸ“Š -### Examples +A diagram type in Mermaid is a plugin. You write a parser, a database, a renderer, and a styles +function, register them under an id, and Mermaid handles detection, lazy loading, theming, and +sanitization for you. + +The use case diagram is the reference implementation for new work. When this guide says "look at +usecase", the files are in `packages/mermaid/src/diagrams/usecase/`. Read them alongside these +steps: they are short, and they show the current conventions rather than the historical ones that +older diagrams still carry. + +## What a diagram is made of + +Each diagram exports a `DiagramDefinition` (`diagram-api/types.ts`) from a single entry file. The +whole of `usecaseDiagram.ts` is this: + +```ts +import type { DiagramDefinition } from '../../diagram-api/types.js'; +import { parser } from './parser/usecase.chevrotain.js'; +import { db } from './usecaseDb.js'; +import { renderer } from './usecaseRenderer.js'; +import styles from './styles.js'; + +export const diagram: DiagramDefinition = { + parser, + db, + renderer, + styles, +}; +``` -> **Warning** -> The below steps are a work in progress and will be updated soon. +| Part | What it does | +| -------- | ------------------------------------------------------------------------------ | +| parser | Turns diagram text into calls on the db. Fails with a useful message otherwise | +| db | Holds the parsed model and hands it to the renderer | +| renderer | Draws the SVG from what the db holds | +| styles | Maps theme variables to CSS for your diagram | +| detector | A regex test that recognizes your diagram's first line. Lives in its own file | + +Two rules apply to everything in your folder: -### Step 1: Grammar & Parsing +Your diagram must be self-contained. Never import from another diagram's folder. You may import +from `diagrams/common/` and from `rendering-util/`, and that is the whole list. Cross-diagram +imports create coupling that breaks unrelated diagrams later, so a reviewer will block on this. + +Your db gets a fresh instance for every render. Do not keep state in module scope, and make sure +`clear()` resets everything. Two diagrams of the same type on one page will otherwise leak into +each other. + +## Step 1: Grammar and parsing New diagram grammars should use [Chevrotain](https://chevrotain.io/docs/), co-located with the diagram itself under `packages/mermaid/src/diagrams//parser/`. The use case diagram is @@ -24,79 +66,103 @@ self-contained and the parser does not have to be released from a separate packa Several existing diagrams (architecture, gitGraph, info, packet, pie, radar, treemap) instead use [Langium](https://langium.org/docs/reference/grammar-language/) grammars in `packages/parser`, and -older diagrams use JISON. Both remain supported β€” modify them in place for bug fixes rather than -rewriting β€” but neither is the target for new work. These PRs show the Langium approach: +older diagrams use JISON. Both remain supported, so modify them in place for bug fixes rather than +rewriting, but neither is the target for new work. These PRs show the Langium approach: - - -### Step 2: Rendering +Whichever you use, invalid input has to produce a parse error with a line and column, never a +stack trace. Mermaid runs inside other people's pages, and a thrown exception there is a broken +page rather than a broken diagram. -Write a renderer that given the data found during parsing renders the diagram. To look at an example look at sequenceRenderer.js rather than the flowchart renderer as this is a more generic example. +## Step 2: The database -Place the renderer in the diagram folder. +The db collects what the parser found and exposes getters for the renderer. Look at +`usecaseDb.ts`. Alongside your own accessors, re-export the shared title and accessibility setters +from `diagrams/common/commonDb.ts` so that authors get the same `title`, `accTitle`, and +`accDescr` syntax they get everywhere else: -### Step 3: Detection of the new diagram type +```js +import { + setAccTitle, + getAccTitle, + getAccDescription, + setAccDescription, + setDiagramTitle, + getDiagramTitle, + clear as commonClear, +} from '../common/commonDb.js'; +``` -The second thing to do is to add the capability to detect the new diagram to type to the detectType in `diagram-api/detectType.ts`. The detection should return a key for the new diagram type. -[This key will be used to as the aria roledescription](#aria-roledescription), so it should be a word that clearly describes the diagram type. -For example, if your new diagram uses a UML deployment diagram, a good key would be "UMLDeploymentDiagram" because assistive technologies such as a screen reader -would voice that as "U-M-L Deployment diagram." Another good key would be "deploymentDiagram" because that would be voiced as "Deployment Diagram." A bad key would be "deployment" because that would not sufficiently describe the diagram. +Your own `clear()` should call `commonClear()` as well as resetting your own fields. -Note that the diagram type key does not have to be the same as the diagram keyword chosen for the [grammar](#grammar), but it is helpful if they are the same. +## Step 3: The renderer -### Common parts of a diagram +Write a renderer that draws the diagram from what the db holds. `usecaseRenderer.ts` is a good +starting point, and `sequenceRenderer.js` is a more generic older example than the flowchart +renderer. The renderer belongs in your diagram folder. -There are a few features that are common between the different types of diagrams. We try to standardize the diagrams that work as similar as possible for the end user. The commonalities are: +Two things are easy to miss and both get flagged in review. -- Directives, a way of modifying the diagram configuration from within the diagram code. -- Accessibility, a way for an author to provide additional information like titles and descriptions to people accessing a text with diagrams using a screen reader. -- Themes, there is a common way to modify the styling of diagrams in Mermaid. -- Comments should follow mermaid standards +Apply the configured padding and hand the sizing to the shared helper, so your diagram scales like +every other diagram instead of rendering at some unrelated size: -Here are some pointers on how to handle these different areas. +```ts +import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; -## Accessibility +setupViewPortForSVG(svg, padding, 'usecaseDiagram', config.useMaxWidth); +``` -Mermaid automatically adds the following accessibility information for the diagram SVG HTML element: +Support handdrawn mode if your drawing approach allows it. The config carries a `look`, and +diagrams check it directly: -- aria-roledescription -- accessible title -- accessible description +```ts +const isHandDrawn = look === 'handDrawn'; +``` -### aria-roledescription +If a third party library makes handdrawn output impossible, that is an acceptable answer, but say +so in your diagram's documentation page so users are not left guessing. -The aria-roledescription is automatically set to [the diagram type](#step-3--detection-of-the-new-diagram-type) and inserted into the SVG element. +## Step 4: Detection and registration -See [the definition of aria-roledescription](https://www.w3.org/TR/wai-aria-1.1/#aria-roledescription) in [the Accessible Rich Internet Applications W3 standard.](https://www.w3.org/WAI/standards-guidelines/aria/) +Detection lives in its own file next to the diagram, not in `detectType.ts`. A detector is a +regex test plus a lazy loader, and it exports an `ExternalDiagramDefinition`: -### accessible title and description +```ts +const id = 'usecase'; -The syntax for accessible titles and descriptions is described in [the Accessibility documentation section.](../config/accessibility.md) +const detector: DiagramDetector = (txt) => { + return /^\s*usecase-beta(?:\s|$)/.test(txt); +}; -The functions for setting title and description are provided by a common module. This is the import in flowDb.js: +const loader: DiagramLoader = async () => { + const { diagram } = await import('./usecaseDiagram.js'); + return { id, diagram }; +}; -``` -import { - setAccTitle, - getAccTitle, - getAccDescription, - setAccDescription, - clear as commonClear, -} from '../../commonDb'; +export const usecase: ExternalDiagramDefinition = { id, detector, loader }; ``` -The accessibility title and description are inserted into the SVG element in the `render` function in mermaidAPI. +Then import it in `diagram-api/diagram-orchestration.ts` and add it to the +`registerLazyLoadedDiagrams(...)` call. Order matters there: the first detector that returns true +wins, so a loose pattern placed early will swallow other diagrams. The loader is what keeps +Mermaid's bundle small, because your diagram is only fetched once someone writes one. -## Theming +[The id becomes the aria roledescription](#aria-roledescription), so pick a word that describes +the diagram out loud. For a UML deployment diagram, "UMLDeploymentDiagram" works, because a screen +reader voices it as "U-M-L Deployment diagram", and so does "deploymentDiagram". "deployment" on +its own does not say enough. -Mermaid supports themes and has an integrated theming engine. You can read more about how the themes can be used [in the docs](../config/theming.md). +The id does not have to match the keyword you chose in the +[grammar](#step-1-grammar-and-parsing), though it helps when they agree. -When adding themes to a diagram it comes down to a few important locations in the code. +## Step 5: Theming -The entry point for the styling engine is in **src/styles.js**. The getStyles function will be called by Mermaid when the styles are being applied to the diagram. +Mermaid has an integrated theming engine, described in more detail [in the docs](../config/theming.md). -This function will in turn call a function _your diagram should provide_ returning the css for the new diagram. The diagram specific, also which is commonly also called getStyles and located in the folder for your diagram under src/diagrams and should be named styles.js. The getStyles function will be called with the theme options as an argument like in the following example: +Your diagram provides a `getStyles` function in `styles.ts` in your diagram folder. It is called +with the resolved theme options and returns CSS: ```js const getStyles = (options) => @@ -110,26 +176,123 @@ const getStyles = (options) => `; ``` -Note that you need to provide your function to the main getStyles by adding it into the themes object in **src/styles.js** like in the xyzDiagram in the provided example: +There is nothing to wire up by hand. `registerDiagram()` passes your `styles` to +`addStylesForDiagram()`, and the styling engine picks it up from there. -```js -const themes = { - flowchart, - 'flowchart-v2': flowchart, - sequence, - xyzDiagram, - //... -}; +Every color must come from `options`. A hardcoded hex value looks fine in the default theme and +then breaks in dark mode, so reviewers treat hardcoded colors as a defect. The values themselves +are defined in the theme files under `src/themes/`; if your diagram needs a variable that does not +exist yet, add it there so all five themes define it. + +## Step 6: Configuration + +If your diagram has options, add them to `src/schemas/config.schema.yaml`, both as an entry in the +list of diagram config keys and as its own config block. Then regenerate the types: + +```bash +pnpm run --filter mermaid types:build-config ``` -The actual options and values for the colors are defined in **src/theme/theme-\[xyz].js**. If you provide the options your diagram needs in the existing theme files then the theming will work smoothly without hiccups. +Never edit `config.type.ts` by hand. It is generated, CI verifies it against the schema, and a +manual edit is a blocking review finding. -## Examples +## Accessibility -The `@mermaid-js/examples` package contains a collection of examples that are used by tools like mermaid.live to help users get started with the new diagram. +Mermaid automatically adds the following accessibility information for the diagram SVG HTML element: -You can duplicate an existing diagram example file, eg: `packages/examples/src/examples/flowchart.ts`, and modify it with details specific to your diagram. +- aria-roledescription +- accessible title +- accessible description + +### aria-roledescription -Then you can import the example in the `packages/examples/src/index.ts` file and add it to the `examples` array. +The aria-roledescription is automatically set to +[the diagram type](#step-4-detection-and-registration) and inserted into the SVG element. + +See [the definition of aria-roledescription](https://www.w3.org/TR/wai-aria-1.1/#aria-roledescription) in [the Accessible Rich Internet Applications W3 standard.](https://www.w3.org/WAI/standards-guidelines/aria/) + +### accessible title and description + +The syntax for accessible titles and descriptions is described in [the Accessibility documentation section.](../config/accessibility.md) -Each diagram should have at least one example, and that should be marked as default. It is good to add more examples to showcase different features of the diagram. +You get both for free once your db re-exports the setters shown in +[Step 2](#step-2-the-database). The values are inserted into the SVG element in the `render` +function in mermaidAPI. + +## Step 7: Tests + +A new diagram without tests will not be merged. There are three kinds, and none of them takes long. + +Unit tests for the parser and db go next to the code as `*.spec.ts`. Cover the syntax you +documented, and cover invalid input too: a diagram that accepts nonsense silently is worse than +one that rejects it. Run them with `vitest run packages/mermaid/src/diagrams/`. + +Visual regression tests come from `.mmd` fixtures. Put one file per scenario in +`e2e/diagrams//`, and that is the whole job: +`e2e/rendering/mmd-snapshots.spec.ts` walks that directory, renders each fixture, and snapshots +it, grouping the results by folder. Screenshot names must be unique across the whole tree, and the +run fails fast if two fixtures collide. `e2e/sheet-order.json` holds the ordering. Run the suite +with `pnpm e2e`. Cover realistic diagrams rather than one minimal smoke test, and include a +fixture per theme if your styling is at all involved. + +A documentation test keeps your examples honest. `usecase.docs.spec.ts` reads the published +`syntax/usecase.md`, extracts every ` ```mermaid-example ` block, and parses it. Copy that pattern +and your documentation cannot drift into examples that no longer work. + +## Step 8: Documentation, demos, and examples + +Write your syntax page as `packages/mermaid/src/docs/syntax/.md`. Edit only the files +under `src/docs/`; the top-level `/docs` folder is generated and your changes there will be +overwritten. Mark the version with the placeholder, as `usecase.md` does with +`# Use case diagrams (+)`, and the release process substitutes the real +number. + +Add the page to the sidebar in `.vitepress/config.ts` under `sidebarSyntax()`. A page with no +sidebar entry is reachable only by URL, which in practice means nobody reads it. + +Add a demo page at `demos/.html` and link it from `demos/index.html`, following any +of the existing demos. + +Add at least one entry to the `@mermaid-js/examples` package, which is what tools like +mermaid.live use to help people get started. Duplicate an existing file such as +`packages/examples/src/examples/flowchart.ts`, adapt it, then import it in +`packages/examples/src/index.ts` and add it to the `examples` array. Mark one example as the +default, and add more to show off individual features. + +If your syntax introduces new keywords, add them to `.cspell/mermaid-terms.txt`. The pre-commit +hook runs CSpell and will otherwise reject the commit. + +## Step 9: Changeset and pull request + +Run `pnpm changeset`, choose the `mermaid` package and a `minor` bump, and write a description +prefixed with `feat:`. + +Open the PR against `develop` and link the issue it resolves. New diagram types are large by +nature, and that is fine, but keep unrelated refactors out of the same branch. + +## Reviewer's checklist + +This is what a reviewer checks. Going through it yourself first is the fastest way to a short +review. + +- [ ] Parser uses Chevrotain, co-located under `diagrams//parser/` +- [ ] Invalid input produces a parse error with position, not a crash +- [ ] `DiagramDefinition` exports parser, db, renderer, and styles +- [ ] Detector in its own file, registered in `diagram-orchestration.ts`, ordered so it does not shadow other diagrams +- [ ] Diagram id reads well as an aria roledescription +- [ ] db holds no module-level state and `clear()` resets everything, including `commonClear()` +- [ ] No imports from other diagrams' folders +- [ ] Renderer applies padding and `useMaxWidth` through `setupViewPortForSVG` +- [ ] Handdrawn mode implemented, or its absence documented +- [ ] `styles.ts` takes theme options, with no hardcoded colors +- [ ] Config options added to `config.schema.yaml` and `config.type.ts` regenerated, never hand-edited +- [ ] Accessibility setters re-exported from `common/commonDb.ts` +- [ ] Unit tests for parser and db, covering invalid input +- [ ] `.mmd` fixtures in `e2e/diagrams//` for visual regression +- [ ] Documentation examples covered by a docs spec +- [ ] Syntax page under `src/docs/syntax/`, with `MERMAID_RELEASE_VERSION` and a sidebar entry +- [ ] Demo page and `demos/index.html` link +- [ ] Example added to `@mermaid-js/examples`, one marked default +- [ ] New keywords added to `.cspell/mermaid-terms.txt` +- [ ] Changeset created (`minor`, `feat:`) +- [ ] PR targets `develop` and links its issue diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index ec529985b81..3ee017ee9aa 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -253,6 +253,7 @@ function sidebarCommunity() { { text: 'Getting Started', link: '/community/intro' }, { text: 'Contributing to Mermaid', link: '/community/contributing' }, { text: 'Adding Diagrams', link: '/community/new-diagram' }, + { text: 'Adding Layouts', link: '/community/layout-makers-guide' }, { text: 'Questions and Suggestions', link: '/community/questions-and-suggestions' }, { text: 'Security', link: '/community/security' }, ], diff --git a/packages/mermaid/src/docs/community/layout-makers-guide.md b/packages/mermaid/src/docs/community/layout-makers-guide.md new file mode 100644 index 00000000000..5b78a873188 --- /dev/null +++ b/packages/mermaid/src/docs/community/layout-makers-guide.md @@ -0,0 +1,520 @@ +# The Layout Maker's Guide πŸ—ΊοΈ + +A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code. Your job is coordinates. + +This guide covers layouts that live inside the Mermaid package, next to `dagre` and the others. The last section explains how to ship the same code as a standalone npm package instead. + +Two layouts in the tree are worth reading alongside it. `dagre` is the default and the oldest. `swimlanes` is the newest, and it is the one that follows the conventions described here, so most of the examples point at it. + +## What a layout receives and what it must produce + +Every layout is handed the same structure, `LayoutData`, regardless of which diagram type produced it: + +```ts +interface LayoutData { + nodes: Node[]; + edges: Edge[]; + config: MermaidConfig; + diagramId?: string; +} +``` + +You must fill in these fields and nothing else: + +| Field | On | Meaning | +| --------------------------- | ---- | ------------------------------------------------------------------- | +| `node.x`, `node.y` | Node | Center of the node, not its top-left corner | +| `node.width`, `node.height` | Node | Already measured for leaf nodes; you set them for groups | +| `edge.points` | Edge | Polyline from source boundary to target boundary, at least 2 points | +| `edge.x`, `edge.y` | Edge | Anchor for the edge label, when the edge has one | + +`Node` and `Edge` carry a good deal more than this, all of it defined in `rendering-util/types.ts`. Read the types there rather than working from what a debugger happens to show you: shape, label geometry, styling, and port information all travel on the same objects, and most of it belongs to the renderer rather than to you. + +Two structural fields are your input, not your output. `node.isGroup` marks a subgraph container, and `node.parentId` names the group a node belongs to. Read them, never rewrite them. + +Sizes arrive already measured. The renderer inserts every node into the SVG, calls `getBBox()`, and writes the result back before your algorithm runs. That measurement is the only step that touches the DOM, which is what makes the rest testable. + +## The five stages + +Layouts are built by calling `createCommonLayoutRenderer` from `rendering-util/layout-algorithms/common/index.ts`. It gives you five hooks, runs them in order, and handles painting: + +```ts +export const render = createCommonLayoutRenderer({ + prepareLayout, // reshape LayoutData before measuring + measureLayout, // DOM: insert elements, read sizes (has a default) + runLayoutCore, // your algorithm, no DOM allowed + paintLayout, // escape hatch: take over painting entirely + afterPaint, // touch up after paths exist + paintOptions, // tweak the standard painter +}); +``` + +Only `runLayoutCore` is required. The swimlanes layout is a complete example in twenty lines: + +```ts +import { createCommonLayoutRenderer } from '../common/index.js'; +import { applySwimlaneLineJumps } from './adjustLayout.js'; +import { prepareLayoutForSwimlanes } from './helpers.js'; +import { createEdgeLabelNodes } from './edgeLabelNodes.js'; +import { runSwimlaneLayoutCore } from './layoutCore.js'; + +function prepareSwimlaneLayout(data4Layout: LayoutData): void { + prepareLayoutForSwimlanes(data4Layout); + + const transformedData = createEdgeLabelNodes(data4Layout); + data4Layout.nodes = transformedData.nodes; + data4Layout.edges = transformedData.edges; +} + +export const render = createCommonLayoutRenderer({ + prepareLayout: prepareSwimlaneLayout, + runLayoutCore: runSwimlaneLayoutCore, + afterPaint: applySwimlaneLineJumps, +}); +``` + +### Keep the core free of the DOM + +`runLayoutCore` must be a pure function of `LayoutData`. No `document`, no `getBBox`, no d3 selections. + +The reason is testing. Node sizes get measured in a browser once and saved to a file. A test then loads those sizes, hands them to `runLayoutCore`, and gets back the same coordinates the browser would have produced, without opening a browser at all. That only holds while the core stays free of the DOM. The moment it reaches for `document`, it can only run inside a real page, and any test around it is exercising a different code path than the one your users hit. + +So write the core as one exported function, and have both the browser and your tests call that same function. + +## A minimal layout + +The examples from here on build a layout called `grid`. No such layout ships with Mermaid. It stands in for whatever you are writing, and the code below is what you would write to create it. + +Put the algorithm in `packages/mermaid/src/rendering-util/layout-algorithms//`. This one arranges leaf nodes in a grid and connects them with straight lines: + +```ts +// layout-algorithms/grid/layoutCore.ts +import type { LayoutData } from '../../types.js'; + +const GAP = 60; + +/** DOM-free: positions come from measured sizes only. */ +export function runGridLayoutCore(data4Layout: LayoutData): void { + const leaves = data4Layout.nodes.filter((node) => !node.isGroup); + const columns = Math.ceil(Math.sqrt(leaves.length)); + const cell = Math.max(...leaves.map((n) => Math.max(n.width ?? 0, n.height ?? 0))) + GAP; + + leaves.forEach((node, i) => { + node.x = (i % columns) * cell; + node.y = Math.floor(i / columns) * cell; + }); + + const byId = new Map(data4Layout.nodes.map((node) => [node.id, node])); + for (const edge of data4Layout.edges) { + const from = byId.get(edge.start ?? ''); + const to = byId.get(edge.end ?? ''); + if (!from || !to) { + continue; + } + edge.points = [ + { x: from.x ?? 0, y: from.y ?? 0 }, + { x: to.x ?? 0, y: to.y ?? 0 }, + ]; + } +} +``` + +```ts +// layout-algorithms/grid/index.ts +import { createCommonLayoutRenderer } from '../common/index.js'; +import { runGridLayoutCore } from './layoutCore.js'; + +export const render = createCommonLayoutRenderer({ runLayoutCore: runGridLayoutCore }); +``` + +That renders. It is also wrong in most of the ways a layout can be wrong. + +## Registering the layout + +The layouts that ship with Mermaid are listed in `registerDefaultLayoutLoaders()` in `packages/mermaid/src/rendering-util/render.ts`. Add one entry for yours: + +```ts +registerLayoutLoaders([ + { name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js') }, + { name: 'swimlane', loader: async () => await import('./layout-algorithms/swimlanes/index.js') }, + // your new layout + { name: 'grid', loader: async () => await import('./layout-algorithms/grid/index.js') }, +]); +``` + +The loader is lazy, so the code only downloads when a diagram asks for it. `cose-bilkent` is registered the same way but wrapped in a check on `includeLargeFeatures`, which is how a layout stays out of the tiny build. Users then select the layout by the name you registered: + +```text +--- +config: + layout: grid +--- +flowchart TB + A --> B +``` + +## Groups, labels, and edges + +### Groups + +A group node carries `isGroup: true`, and its members carry `parentId` pointing at it. Groups nest. Your algorithm owns the group's `x`, `y`, `width`, and `height`, and the frame must enclose every descendant with room for the title. Groups also have an optional `groupTitleRect` describing the header band. Edges routed through that band collide with the title text. + +The standard painter draws a group as a cluster and everything else as a node. Override that with `paintOptions.isCluster` when your layout has its own idea of which nodes are containers. + +### Edge labels + +Labels need space reserved before positions are decided, otherwise they land on top of edges and nodes. The approach that works is to split each labelled edge into `start β†’ label β†’ end` around a temporary node, let the algorithm place that node like any other, then fold it back into an overlay label. Swimlanes does this in `createEdgeLabelNodes`, called from its `prepareLayout` hook so the dummy node is measured as real text along with everything else. + +Invent a third mechanism and your labels will not be checked by the validator, because the validator reads the label geometry this pattern produces. + +### Edge endpoints and markers + +Edge paths must start and end on the boundary of their nodes, not at the center and not floating in space. By default the painter recomputes the endpoint by intersecting the path with the node shape, which will quietly bend the last segment of a carefully routed edge. Layouts that route to exact ports should turn that off with `paintOptions.skipIntersect`. + +Arrowheads occupy roughly the last ten pixels of the final segment. A bend inside that stretch puts a corner underneath the arrowhead, and the validator calls it: the constant is `EPS_FINAL_APPROACH`, and it is 10. + +### Self-loops and parallel edges + +An edge with `start === end` has no direction to follow and needs its own route, usually a small rectangle off one side of the node. Parallel edges between the same pair need to be separated by hand, or they render as one line. Fixtures for both live in `e2e/platform/dev-diagrams/layout-tests/` as `self-loop.mmd`, `self-loop-2.mmd`, `self-loop-multi.mmd`, and `identical-edges.mmd`. + +## Validating the result + +`validateLayout` in `layout-algorithms/layout-utils/validateLayout.ts` is the shared judge of layout quality. It takes finished `LayoutData` and returns a verdict plus a score: + +```ts +import { validateLayout } from '../layout-utils/validateLayout.js'; + +const result = validateLayout(layout); +// result.ok β†’ boolean, false when any hard constraint is broken +// result.issues β†’ what broke, with node/edge ids and details +// result.score β†’ 0 to 1000; exactly 0 whenever ok is false +// result.breakdown β†’ crossings, per-edge bend penalties, point histogram +``` + +### Hard constraints + +`ok` is false if any issue is present, and the score drops to zero. These are the failures worth knowing about before you write your router: + +| Issue | What it means | +| --------------------------------------------------------------- | ----------------------------------------------------- | +| `node-overlap` | Two nodes occupy the same space | +| `edge-intersects-node`, `edge-intersects-obstacle` | A path crosses a node it does not connect to | +| `edge-intersects-group-title` | A path runs through a subgraph's title band | +| `edge-endpoint-detached-from-node`, `edge-endpoint-inside-node` | An endpoint misses the boundary | +| `edge-non-orthogonal` | A segment is neither horizontal nor vertical | +| `edge-missing-points` | Fewer than two points on an edge | +| `edge-shared-subpath`, `edge-parallel-segment-too-close` | Two edges overlap or run too close to tell apart | +| `edge-shared-attachment-point`, `edge-same-port-departure` | Two edges leave a node from the same spot | +| `edge-bend-near-endpoint`, `edge-bend-overlaps-arrowhead` | A corner sits under the arrowhead or against the node | +| `edge-border-hugging`, `node-border-hugging` | Geometry runs along a border instead of clear of it | +| `edge-label-overlaps-node`, `edge-label-overlaps-foreign-edge` | A label lands on top of something else | +| `edge-label-off-edge` | A label sits away from the edge it belongs to | + +The tolerances are named constants at the top of `validateLayout.ts`, and two of them explain most first-time surprises: `EPS_FINAL_APPROACH` is 10, the stretch near an endpoint where a bend is not allowed, and `EPS_SHARED_ATTACH` is 3, how close two edges may attach to the same node before they count as sharing a point. The tests assert relative ordering rather than exact magnitudes, so treat the numbers as current rather than fixed. + +Some checks assume orthogonal routing. A layout with curved or diagonal edges will trip `edge-non-orthogonal` on every edge, so either route orthogonally or work out which subset of the validator applies to you before treating its score as a target. + +### The score + +When `ok` is true the score starts at 1000 and comes down. Bends are counted per edge from the polyline point count, and crossings are counted once globally: + +| Polyline points | Bends | Penalty | +| --------------- | ----- | ------------------- | +| 2 | 0 | 0 | +| 3 | 1 | 0 | +| 4 | 2 | 5 | +| 5 | 3 | 12 | +| 6 | 4 | 30 | +| 7 or more | 5+ | 30 Γ— 2^(points βˆ’ 6) | + +Each crossing costs 3. The curve is deliberately steep at the top end: one seven-bend edge costs more than twenty crossings, because a path nobody can follow is worse than a tidy diagram with intersections. + +The score does not scale with diagram size. A small graph reaches 1000; a large one rarely will, so compare a fixture against its own history rather than against another fixture. + +`result.breakdown.edges` is sorted worst-first, which makes it the fastest way to find what is dragging a layout down. + +### One thing that looks useful and is not + +`layout-utils` also holds `scoreLayout`, which computes softer metrics: aspect ratio, average bends per edge, rank faithfulness, neighborhood preservation, straight-edge ratio. Sitting next to `validateLayout`, it looks like the quality half of a matched pair. + +It is not wired into anything. Nothing in the layout pipeline calls it, no fixture spec calls it, and its only caller is its own unit test. Its `symmetryScore` is an unfinished placeholder that always returns `NaN`. Treat `validateLayout` as the only judge, and leave `scoreLayout` alone unless you are deliberately picking up that unfinished work. + +A third level is planned and not built. `compareLayoutSnapshot()` would diff a layout's structure against a stored baseline to catch regressions that stay inside the validator's tolerances. There is no such function today, so nothing depends on it. + +## Testing with DDLT + +DOM-Decoupled Layout Testing runs your algorithm in Node against sizes captured once from a real browser. Tests come back in seconds and give the same answer every time, and because the browser and the tests call the same core function, a fix in one is a fix in the other. + +### Tests must run the browser's code path + +The model is `parse β†’ measure β†’ run layout β†’ paint`, and the third step has to be a single function. Tests swap out the measuring; the browser does it for real. What runs in between must be identical. + +That sounds obvious, and it is where this gets broken most often. A test-only layout entry point sitting alongside the browser's is a bug even when the two look like they do the same thing. This has bitten this repo more than once, and the shape is always the same. The browser entry wrapped the edge pipeline in a degeneracy check and a direction-violation check, with a fallback and a reroute branch hanging off them. The test backend called the pipeline directly and skipped all of that. Fixtures came back valid, the browser took the fallback, and the fallback drew a polyline straight through the interior of a node. The validator would have caught it. The test harness never saw it. + +So when you wire up a test backend, find the function the browser actually calls and trace it end to end. If it needs the DOM, lift its DOM-free body into a helper and call that helper from both sides rather than reimplementing the sequence. Never point the test at a primitive that the browser wraps in checks, fallbacks, mirror branches, or second passes: the test has to include all of it. + +There is a cheap way to prove the seam is real. Change the browser orchestration, add a fallback or switch a default, and the test result for the same fixture should move. If it does not, the test is running around your change. Fix the seam rather than relaxing the test. + +When a fixture passes in Node but looks broken on screen, run `validateLayout` on both, on the same fixture, and compare the issues. Different issues mean different pipelines. Put the two call chains side by side, look for pre-passes, the main call, and post-passes, and the first place they diverge is the seam. + +### Fixtures + +A fixture is a pair of files under `e2e/platform/dev-diagrams/layout-tests/`: + +``` +layout-tests/ + simple-graph.mmd ← real Mermaid source, parsed by the real parser + simple-graph.sizes.json ← node dimensions captured from a browser render + ddlt-manifest.json ← per-fixture overrides +``` + +The `.mmd` file goes through the actual parser, so fixtures exercise the same `LayoutData` a user's diagram produces. The `.sizes.json` file holds one entry per leaf node and one per edge label, plus freshness metadata: + +```json +{ + "metadata": { + "captureVersion": 1, + "sourceSha256": "…", + "capturedAt": "2026-02-09T10:00:00Z", + "capturedFrom": "theme=default&look=classic" + }, + "nodes": [{ "id": "A", "width": 62, "height": 39 }] +} +``` + +`sourceSha256` is a hash of the `.mmd` file. Edit the diagram without recapturing and the test fails with a stale-fixture error instead of silently laying out with wrong sizes. + +`ddlt-manifest.json` gives a fixture a profile, which decides the backend it runs through, and can mark it `allowLevel1Failure` when a known failure is tracked in its own dedicated spec. + +### Capturing sizes + +There is a button for this. Start the dev server with `pnpm dev`, open the explorer at `/dev/`, and pick your diagram out of the file tree, which is rooted at the same `dev-diagrams` folder the fixtures live in. Switch to the Code tab and click **Save sizes**, sitting next to Save. + +It does the whole job. Unsaved edits to the diagram are written first, the diagram re-renders with size capture switched on, and the measurements go to `.sizes.json` beside the `.mmd`. The hash and the capture version are filled in for you, so the fixture passes the freshness check the moment it lands. + +The button is only enabled for layouts that can produce capture data, and it explains itself through a tooltip when it is greyed out. + +If you need to capture from somewhere other than the explorer, the same machinery is reachable from the console: + +```js +window.mermaidCaptureSizes = true; +// render the diagram, then: +copy(JSON.stringify(window.mermaidLastCapturedSizes.sizes, null, 2)); +``` + +Written by hand this way, the metadata block is yours to fill in. The capture module is dynamically imported only when that flag is set, so it never reaches a production bundle either way. + +Recapture when the diagram source changes or when a shape's real dimensions change. Do not recapture to make a failing test pass. The fixture is the before-picture, and rewriting it erases the regression you were trying to catch. + +### Parse the diagram, do not hand-build the graph + +It is tempting to skip the parser and write the `LayoutData` for a test by hand. Resist it. Hand-built graphs drift from what the parser emits, usually in the identifiers, and once the ids differ the test is scoring a graph the browser never lays out. + +The ids follow rules worth knowing, because fixture entries are matched against them: + +| Entity | Id | +| ----------------- | ------------------------------------------------------------------ | +| Content node | The unquoted name from the diagram source, such as `A` or `E` | +| Unquoted subgraph | The subgraph text, kept as written | +| Quoted subgraph | `subGraph`, numbered by a counter that ticks on every subgraph | +| Edge | `L___`, the counter separating parallel edges | +| Edge label node | `edge-label---` | + +The harness does the parsing for you. `parseMmdFileToLayoutData` strips frontmatter and directives, runs the real detector and parser, and stamps the direction the way the flowchart renderer does. `parseApplySizesAndLayout` goes further and applies the captured sizes and runs a backend. Reach for those before writing your own. + +If you do drive the parser yourself, two calls come first: `addDiagrams()` to register the diagram types, and `preprocessDiagram()` to handle frontmatter before `Diagram.fromText()` sees it. Skipping either produces failures that look like parser bugs and are not. + +Whichever route you take, fail loudly when a fixture entry has no matching parser-produced node. A silent miss leaves a node at its default size, and the layout you then measure is not the layout anyone will see. + +### Writing a spec + +Load the fixture, run your backend, assert on the result: + +```ts +import { describe, it, expect, beforeAll } from 'vitest'; +import { loadDdltFixture } from '../ddlt/index.js'; +import { validateLayout } from '../layout-utils/validateLayout.js'; + +describe('swimlanes, 1-simple', () => { + let layout: LayoutData; + beforeAll(async () => { + layout = await loadDdltFixture('swimlanes/1-simple', { backendId: 'swimlanes' }); + }); + + it('produces a valid layout', () => { + const result = validateLayout(layout); + expect(result.issues.map((i) => i.type)).toEqual([]); + expect(result.score).toBeGreaterThan(900); + }); +}); +``` + +Pass `backendId` explicitly. The default is a backend that is not present on `develop`, so a call without it throws rather than silently doing something reasonable. Your own layout needs an entry in `ddlt/backends.ts` before a fixture can run through it. + +`ddlt/index.ts` also exports `baselineDdltSpec(name)`, a one-liner that asserts the universal invariants: finite coordinates, at least two points per edge, no segment through an unrelated node, endpoints on boundaries. It hardcodes the same absent default backend and has no callers, so read it for the invariants it checks rather than calling it as it stands. + +Three shapes of test cover most needs. A tiny inline geometry test, where you build a handful of nodes by hand and check one routing rule, is right for a unit of the algorithm. A fixture-backed test on realistic sizes is right for a regression you can point at. A full source-to-layout run through the parser is right for anything a user reported. Match the surrounding folder: `swimlanes/query-process.ddlt.spec.ts` is the fullest worked example in the tree, and `layout-utils/validateLayout.spec.ts` and `ddlt/aggregateValidate.spec.ts` show the smaller shapes. + +### The sweep + +`ddlt/layout-fixtures.ddlt.spec.ts` discovers fixture pairs, runs them, and asserts validity across the board. It also emits an aggregate report, the number that tells you whether a change helped overall rather than on the one diagram you were staring at: + +```bash +# The sweep, with the aggregate report +pnpm exec vitest run \ + packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts + +# Just the aggregate line +pnpm exec vitest run \ + packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts \ + 2>&1 | grep 'DDLT-AGG' + +# One fixture while iterating +pnpm exec vitest run -t "1-simple" \ + packages/mermaid/src/rendering-util/layout-algorithms/ddlt/ +``` + +The report gives `total`, `avg`, `min`, and `invalid`, then one row per fixture with its score and issue types. Read it as a work queue: the lowest row is where the next improvement is. `ORTHO_TEST_DEBUG=1` in front of the command turns the layout logger from `fatal` up to `debug`, which helps when a row fails for reasons the report does not explain. + +The sweep as written filters to the `swimlanes` profile and holds that profile's total against a floor. Adding a layout means adding its profile to the manifest and its own aggregate assertion, not assuming the existing one will pick your fixtures up. + +## The cases that break layouts + +A layout that handles a chain of boxes tells you almost nothing. The cases below are where engines actually fail, roughly in the order yours will fail them. Every one has a diagram in `layout-tests` already, so there is nothing to write before you can find out. + +Most of them are source only. The folder holds around 45 diagrams and about 15 have captured sizes, and the sweep discovers fixtures by looking for `.sizes.json` files and pairing each with its sibling `.mmd`. A diagram with no sizes file is not being tested by anything, however tricky it looks. Check before assuming a case is covered, and if the sizes are missing, open the diagram in the explorer and press Save sizes. That one click is the difference between a diagram sitting in a folder and a case the sweep will defend. + +### Self-loops + +An edge whose source and target are the same node has no direction to travel in, and code that computes a route from two distinct positions tends to produce a zero-length path, a division by zero, or a dot. The route has to be manufactured: a small loop off one side, clear of the node and of anything the node's other edges are doing. + +`self-loop.mmd` is the single-node case, and the only one of the three with sizes captured. `self-loop-2.mmd` and `self-loop-multi.mmd` are harsher, putting a self-loop on all four nodes of a cycle, so every loop competes for space with real edges already using those sides. + +### Subgraphs + +This is the long tail, and it is where most of the work is. A subgraph is a node that contains other nodes, so every edge endpoint now has two possible meanings and every frame is an obstacle that also has to move as its contents move. + +| Case | Fixture | +| ------------------------------------- | --------------------------------------------------------- | +| A subgraph standing on its own | `decoupled-subgraph.mmd` | +| Edge into a subgraph | `edge-to-subgraph.mmd` | +| Edge into a node inside a subgraph | `edge-to-node-in-subgraph.mmd` | +| Edge out of a subgraph | `edge-from-subgraph.mmd` | +| Edge from inside out to a plain node | `subgraph-variation.mmd`, `subgraph-variation-2.mmd` | +| Between two sibling subgraphs | `nested-sg-outgoing-2.mmd`, `nested-incoming.mmd` | +| Inside one subgraph to inside another | `nested-sg-outgoing-2.mmd`, `nested-subgraphs-2.mmd` | +| Nested subgraphs | `nested-subgraphs.mmd`, `nested-subgraphs-3.mmd` | +| Edges crossing several nesting levels | `nested-sb-edges-in-out.mmd`, `nested-outgoing-edges.mmd` | +| Subgraph titles competing for space | `subgraph-labels.mmd` and its two variants | + +Work down that list in order. Each row assumes the ones above it. Only the first two rows have captured sizes today, so everything below them is a diagram you can open in the browser rather than a test that will tell you when you break it. + +Two failures recur. An edge that ends on a subgraph should stop at the frame rather than diving through to a member, and an edge that ends on a member has to cross the frame without clipping the title band. The other is sizing: a frame has to enclose everything inside it including the labels, and it has to keep doing so after a later pass nudges a member. + +### Parallel edges + +Two edges between the same pair of nodes are one edge as far as most routing code is concerned, because both get the same endpoints and the same optimal path, so they land exactly on top of each other and the diagram silently loses information. They have to be separated deliberately. + +`identical-edges.mmd` is the minimal case. `multiple-edges.mmd` adds a reverse edge to the bundle, so the fix cannot just fan edges out by index and ignore direction. `identical-edges-in-subgraph.mmd` puts a bundle in each direction inside a frame, where the room to fan out is bounded. + +### Busy nodes + +A node with more than four edges cannot give each one its own side. Ports have to share sides, share sides in an order that does not cross, and stay far enough apart to be told apart. Engines that assign one edge per side degrade sharply here, usually into a knot right against the node. + +`edge-types.mmd` piles several edges onto a single node with a different arrow type on each. `Company.mmd` and `Company-simp.mmd` are the realistic version of the same problem, and both have sizes captured. + +### Combinations, and both directions + +These interact, and the combinations are worse than the parts. A self-loop on a busy node inside a nested subgraph exercises all four at once, which is why the larger fixtures are worth keeping even though a failure in one is harder to diagnose. `deploy-pipeline.mmd`, `life-choices.mmd`, and `project-sox2.mmd` are the closest thing here to diagrams a user would actually write. + +Run the ones that matter in `TB` and `LR` both. Layout code tends to grow an implicit assumption about which way the graph flows, and the second direction is where that assumption surfaces. + +## Before you invent something + +Graph drawing has a long research record, and most of what a layout engine needs has been studied for decades. Orthogonal routing, compaction, port and side constraints, layered pipelines, crossing minimisation: none of these are new problems, and reading up on one is usually faster than deriving a heuristic and discovering its failure modes one fixture at a time. + +The vocabulary gap is worth knowing about, because it makes searching harder than it needs to be. What this codebase calls a jog, the literature calls a bend. A port window is a pin or a side constraint. A rail is a track or a channel. A group is a compound vertex. + +When you knowingly diverge from what the established approach recommends, write down why in the pull request, along with how you checked that the divergence works. + +## Watching it in the browser while the tests run + +The sweep tells you a score dropped. It does not tell you the diagram now looks like a plate of spaghetti. Keep a browser open next to the test run. + +```bash +pnpm dev +``` + +Do not assume the address. The port is derived from the path of the checkout, so every worktree and every clone gets its own and you can run several dev servers side by side without them fighting over 9000. The server prints its URL as it starts, before the build output scrolls past. `MERMAID_DEV_PORT` pins it if you want a fixed one. + +Open `/dev/` and you get the explorer: the fixture tree on one side, the diagram on the other, a code tab for editing the source, and a layout picker for comparing your algorithm against the others on the same input. It reloads when you change the source, so an edit to your algorithm redraws the diagram without you touching the browser. + +For a diagram that is not in the fixture tree, copy the standalone page template instead: + +```bash +cp demos/dev/example.html demos/dev/grid.html +``` + +That lands at `/dev/grid.html` on the same server. Put the diagram in the page and name your layout in the frontmatter: + +```html +
+---
+config:
+  layout: grid
+---
+flowchart TB
+  A --> B
+  B --> C
+
+``` + +A workflow that holds up over a long session: + +1. Run the sweep in one terminal, filtered to the fixture you are working on. +2. Keep that same fixture open in the browser. +3. Make one change and watch both. The score says whether it helped, and the picture says whether the score was measuring the right thing. +4. Before committing, run the full sweep and confirm the aggregate did not drop. + +The two disagree more often than you would expect, and the disagreement is informative. A score that improves while the diagram gets worse means the validator is blind to something, and that gap is worth writing down. + +Use the browser to check what the tests cannot see: text that overflows its shape, arrowheads pointing the wrong way, subgraph frames cutting through labels. + +## Shipping as a separate package + +An external layout uses the same `render` signature and the same `createCommonLayoutRenderer`. Instead of editing the built-in registry, export a loader array and let the consumer register it: + +```ts +import type { LayoutLoaderDefinition } from 'mermaid'; + +const loader = async () => await import('./render.js'); + +const layouts: LayoutLoaderDefinition[] = [{ name: 'grid', loader, algorithm: 'grid.compact' }]; + +export default layouts; +``` + +```js +import mermaid from 'mermaid'; +import layouts from 'my-mermaid-layout'; + +mermaid.registerLayoutLoaders(layouts); +``` + +The `algorithm` field is passed back to your renderer through `options`, which lets one package register several named variants that share an implementation. `packages/mermaid-layout-elk` does exactly this. + +Package it separately when the layout pulls in a large dependency. Everything else belongs in the main package, where it gets covered by the fixture sweep. + +## Checklist + +- [ ] `runLayoutCore` is one exported function with no DOM access +- [ ] The browser and the tests call that same function +- [ ] Group frames enclose their members and leave the title band clear +- [ ] Edge labels reserve space before positions are decided +- [ ] Edge endpoints land on node boundaries +- [ ] Self-loops and parallel edges have routes +- [ ] `validateLayout` returns `ok: true` on your fixtures +- [ ] Fixtures exist, with captured sizes, for the cases the algorithm was written to handle +- [ ] Your backend is registered in `ddlt/backends.ts` and your profile in `ddlt-manifest.json` +- [ ] The sweep passes and the aggregate score has not dropped +- [ ] `.mmd` fixtures under `e2e/diagrams/` cover the layout visually, since layout changes are rendering changes diff --git a/packages/mermaid/src/docs/community/new-diagram.md b/packages/mermaid/src/docs/community/new-diagram.md index 0c9b409609c..7d277faf3d9 100644 --- a/packages/mermaid/src/docs/community/new-diagram.md +++ b/packages/mermaid/src/docs/community/new-diagram.md @@ -1,12 +1,53 @@ # Adding a New Diagram/Chart πŸ“Š -### Examples - -```warning -The below steps are a work in progress and will be updated soon. +A diagram type in Mermaid is a plugin. You write a parser, a database, a renderer, and a styles +function, register them under an id, and Mermaid handles detection, lazy loading, theming, and +sanitization for you. + +The use case diagram is the reference implementation for new work. When this guide says "look at +usecase", the files are in `packages/mermaid/src/diagrams/usecase/`. Read them alongside these +steps: they are short, and they show the current conventions rather than the historical ones that +older diagrams still carry. + +## What a diagram is made of + +Each diagram exports a `DiagramDefinition` (`diagram-api/types.ts`) from a single entry file. The +whole of `usecaseDiagram.ts` is this: + +```ts +import type { DiagramDefinition } from '../../diagram-api/types.js'; +import { parser } from './parser/usecase.chevrotain.js'; +import { db } from './usecaseDb.js'; +import { renderer } from './usecaseRenderer.js'; +import styles from './styles.js'; + +export const diagram: DiagramDefinition = { + parser, + db, + renderer, + styles, +}; ``` -### Step 1: Grammar & Parsing +| Part | What it does | +| -------- | ------------------------------------------------------------------------------ | +| parser | Turns diagram text into calls on the db. Fails with a useful message otherwise | +| db | Holds the parsed model and hands it to the renderer | +| renderer | Draws the SVG from what the db holds | +| styles | Maps theme variables to CSS for your diagram | +| detector | A regex test that recognizes your diagram's first line. Lives in its own file | + +Two rules apply to everything in your folder: + +Your diagram must be self-contained. Never import from another diagram's folder. You may import +from `diagrams/common/` and from `rendering-util/`, and that is the whole list. Cross-diagram +imports create coupling that breaks unrelated diagrams later, so a reviewer will block on this. + +Your db gets a fresh instance for every render. Do not keep state in module scope, and make sure +`clear()` resets everything. Two diagrams of the same type on one page will otherwise leak into +each other. + +## Step 1: Grammar and parsing New diagram grammars should use [Chevrotain](https://chevrotain.io/docs/), co-located with the diagram itself under `packages/mermaid/src/diagrams//parser/`. The use case diagram is @@ -19,79 +60,103 @@ self-contained and the parser does not have to be released from a separate packa Several existing diagrams (architecture, gitGraph, info, packet, pie, radar, treemap) instead use [Langium](https://langium.org/docs/reference/grammar-language/) grammars in `packages/parser`, and -older diagrams use JISON. Both remain supported β€” modify them in place for bug fixes rather than -rewriting β€” but neither is the target for new work. These PRs show the Langium approach: +older diagrams use JISON. Both remain supported, so modify them in place for bug fixes rather than +rewriting, but neither is the target for new work. These PRs show the Langium approach: - https://github.com/mermaid-js/mermaid/pull/4839 - https://github.com/mermaid-js/mermaid/pull/4751 -### Step 2: Rendering +Whichever you use, invalid input has to produce a parse error with a line and column, never a +stack trace. Mermaid runs inside other people's pages, and a thrown exception there is a broken +page rather than a broken diagram. -Write a renderer that given the data found during parsing renders the diagram. To look at an example look at sequenceRenderer.js rather than the flowchart renderer as this is a more generic example. +## Step 2: The database -Place the renderer in the diagram folder. +The db collects what the parser found and exposes getters for the renderer. Look at +`usecaseDb.ts`. Alongside your own accessors, re-export the shared title and accessibility setters +from `diagrams/common/commonDb.ts` so that authors get the same `title`, `accTitle`, and +`accDescr` syntax they get everywhere else: -### Step 3: Detection of the new diagram type +```js +import { + setAccTitle, + getAccTitle, + getAccDescription, + setAccDescription, + setDiagramTitle, + getDiagramTitle, + clear as commonClear, +} from '../common/commonDb.js'; +``` -The second thing to do is to add the capability to detect the new diagram to type to the detectType in `diagram-api/detectType.ts`. The detection should return a key for the new diagram type. -[This key will be used to as the aria roledescription](#aria-roledescription), so it should be a word that clearly describes the diagram type. -For example, if your new diagram uses a UML deployment diagram, a good key would be "UMLDeploymentDiagram" because assistive technologies such as a screen reader -would voice that as "U-M-L Deployment diagram." Another good key would be "deploymentDiagram" because that would be voiced as "Deployment Diagram." A bad key would be "deployment" because that would not sufficiently describe the diagram. +Your own `clear()` should call `commonClear()` as well as resetting your own fields. -Note that the diagram type key does not have to be the same as the diagram keyword chosen for the [grammar](#grammar), but it is helpful if they are the same. +## Step 3: The renderer -### Common parts of a diagram +Write a renderer that draws the diagram from what the db holds. `usecaseRenderer.ts` is a good +starting point, and `sequenceRenderer.js` is a more generic older example than the flowchart +renderer. The renderer belongs in your diagram folder. -There are a few features that are common between the different types of diagrams. We try to standardize the diagrams that work as similar as possible for the end user. The commonalities are: +Two things are easy to miss and both get flagged in review. -- Directives, a way of modifying the diagram configuration from within the diagram code. -- Accessibility, a way for an author to provide additional information like titles and descriptions to people accessing a text with diagrams using a screen reader. -- Themes, there is a common way to modify the styling of diagrams in Mermaid. -- Comments should follow mermaid standards +Apply the configured padding and hand the sizing to the shared helper, so your diagram scales like +every other diagram instead of rendering at some unrelated size: -Here are some pointers on how to handle these different areas. +```ts +import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; -## Accessibility +setupViewPortForSVG(svg, padding, 'usecaseDiagram', config.useMaxWidth); +``` -Mermaid automatically adds the following accessibility information for the diagram SVG HTML element: +Support handdrawn mode if your drawing approach allows it. The config carries a `look`, and +diagrams check it directly: -- aria-roledescription -- accessible title -- accessible description +```ts +const isHandDrawn = look === 'handDrawn'; +``` -### aria-roledescription +If a third party library makes handdrawn output impossible, that is an acceptable answer, but say +so in your diagram's documentation page so users are not left guessing. -The aria-roledescription is automatically set to [the diagram type](#step-3--detection-of-the-new-diagram-type) and inserted into the SVG element. +## Step 4: Detection and registration -See [the definition of aria-roledescription](https://www.w3.org/TR/wai-aria-1.1/#aria-roledescription) in [the Accessible Rich Internet Applications W3 standard.](https://www.w3.org/WAI/standards-guidelines/aria/) +Detection lives in its own file next to the diagram, not in `detectType.ts`. A detector is a +regex test plus a lazy loader, and it exports an `ExternalDiagramDefinition`: -### accessible title and description +```ts +const id = 'usecase'; -The syntax for accessible titles and descriptions is described in [the Accessibility documentation section.](../config/accessibility.md) +const detector: DiagramDetector = (txt) => { + return /^\s*usecase-beta(?:\s|$)/.test(txt); +}; -The functions for setting title and description are provided by a common module. This is the import in flowDb.js: +const loader: DiagramLoader = async () => { + const { diagram } = await import('./usecaseDiagram.js'); + return { id, diagram }; +}; -``` -import { - setAccTitle, - getAccTitle, - getAccDescription, - setAccDescription, - clear as commonClear, -} from '../../commonDb'; +export const usecase: ExternalDiagramDefinition = { id, detector, loader }; ``` -The accessibility title and description are inserted into the SVG element in the `render` function in mermaidAPI. +Then import it in `diagram-api/diagram-orchestration.ts` and add it to the +`registerLazyLoadedDiagrams(...)` call. Order matters there: the first detector that returns true +wins, so a loose pattern placed early will swallow other diagrams. The loader is what keeps +Mermaid's bundle small, because your diagram is only fetched once someone writes one. -## Theming +[The id becomes the aria roledescription](#aria-roledescription), so pick a word that describes +the diagram out loud. For a UML deployment diagram, "UMLDeploymentDiagram" works, because a screen +reader voices it as "U-M-L Deployment diagram", and so does "deploymentDiagram". "deployment" on +its own does not say enough. -Mermaid supports themes and has an integrated theming engine. You can read more about how the themes can be used [in the docs](../config/theming.md). +The id does not have to match the keyword you chose in the +[grammar](#step-1-grammar-and-parsing), though it helps when they agree. -When adding themes to a diagram it comes down to a few important locations in the code. +## Step 5: Theming -The entry point for the styling engine is in **src/styles.js**. The getStyles function will be called by Mermaid when the styles are being applied to the diagram. +Mermaid has an integrated theming engine, described in more detail [in the docs](../config/theming.md). -This function will in turn call a function _your diagram should provide_ returning the css for the new diagram. The diagram specific, also which is commonly also called getStyles and located in the folder for your diagram under src/diagrams and should be named styles.js. The getStyles function will be called with the theme options as an argument like in the following example: +Your diagram provides a `getStyles` function in `styles.ts` in your diagram folder. It is called +with the resolved theme options and returns CSS: ```js const getStyles = (options) => @@ -105,26 +170,123 @@ const getStyles = (options) => `; ``` -Note that you need to provide your function to the main getStyles by adding it into the themes object in **src/styles.js** like in the xyzDiagram in the provided example: +There is nothing to wire up by hand. `registerDiagram()` passes your `styles` to +`addStylesForDiagram()`, and the styling engine picks it up from there. -```js -const themes = { - flowchart, - 'flowchart-v2': flowchart, - sequence, - xyzDiagram, - //... -}; +Every color must come from `options`. A hardcoded hex value looks fine in the default theme and +then breaks in dark mode, so reviewers treat hardcoded colors as a defect. The values themselves +are defined in the theme files under `src/themes/`; if your diagram needs a variable that does not +exist yet, add it there so all five themes define it. + +## Step 6: Configuration + +If your diagram has options, add them to `src/schemas/config.schema.yaml`, both as an entry in the +list of diagram config keys and as its own config block. Then regenerate the types: + +```bash +pnpm run --filter mermaid types:build-config ``` -The actual options and values for the colors are defined in **src/theme/theme-[xyz].js**. If you provide the options your diagram needs in the existing theme files then the theming will work smoothly without hiccups. +Never edit `config.type.ts` by hand. It is generated, CI verifies it against the schema, and a +manual edit is a blocking review finding. -## Examples +## Accessibility -The `@mermaid-js/examples` package contains a collection of examples that are used by tools like mermaid.live to help users get started with the new diagram. +Mermaid automatically adds the following accessibility information for the diagram SVG HTML element: -You can duplicate an existing diagram example file, eg: `packages/examples/src/examples/flowchart.ts`, and modify it with details specific to your diagram. +- aria-roledescription +- accessible title +- accessible description + +### aria-roledescription -Then you can import the example in the `packages/examples/src/index.ts` file and add it to the `examples` array. +The aria-roledescription is automatically set to +[the diagram type](#step-4-detection-and-registration) and inserted into the SVG element. + +See [the definition of aria-roledescription](https://www.w3.org/TR/wai-aria-1.1/#aria-roledescription) in [the Accessible Rich Internet Applications W3 standard.](https://www.w3.org/WAI/standards-guidelines/aria/) + +### accessible title and description + +The syntax for accessible titles and descriptions is described in [the Accessibility documentation section.](../config/accessibility.md) -Each diagram should have at least one example, and that should be marked as default. It is good to add more examples to showcase different features of the diagram. +You get both for free once your db re-exports the setters shown in +[Step 2](#step-2-the-database). The values are inserted into the SVG element in the `render` +function in mermaidAPI. + +## Step 7: Tests + +A new diagram without tests will not be merged. There are three kinds, and none of them takes long. + +Unit tests for the parser and db go next to the code as `*.spec.ts`. Cover the syntax you +documented, and cover invalid input too: a diagram that accepts nonsense silently is worse than +one that rejects it. Run them with `vitest run packages/mermaid/src/diagrams/`. + +Visual regression tests come from `.mmd` fixtures. Put one file per scenario in +`e2e/diagrams//`, and that is the whole job: +`e2e/rendering/mmd-snapshots.spec.ts` walks that directory, renders each fixture, and snapshots +it, grouping the results by folder. Screenshot names must be unique across the whole tree, and the +run fails fast if two fixtures collide. `e2e/sheet-order.json` holds the ordering. Run the suite +with `pnpm e2e`. Cover realistic diagrams rather than one minimal smoke test, and include a +fixture per theme if your styling is at all involved. + +A documentation test keeps your examples honest. `usecase.docs.spec.ts` reads the published +`syntax/usecase.md`, extracts every ` ```mermaid-example ` block, and parses it. Copy that pattern +and your documentation cannot drift into examples that no longer work. + +## Step 8: Documentation, demos, and examples + +Write your syntax page as `packages/mermaid/src/docs/syntax/.md`. Edit only the files +under `src/docs/`; the top-level `/docs` folder is generated and your changes there will be +overwritten. Mark the version with the placeholder, as `usecase.md` does with +`# Use case diagrams (+)`, and the release process substitutes the real +number. + +Add the page to the sidebar in `.vitepress/config.ts` under `sidebarSyntax()`. A page with no +sidebar entry is reachable only by URL, which in practice means nobody reads it. + +Add a demo page at `demos/.html` and link it from `demos/index.html`, following any +of the existing demos. + +Add at least one entry to the `@mermaid-js/examples` package, which is what tools like +mermaid.live use to help people get started. Duplicate an existing file such as +`packages/examples/src/examples/flowchart.ts`, adapt it, then import it in +`packages/examples/src/index.ts` and add it to the `examples` array. Mark one example as the +default, and add more to show off individual features. + +If your syntax introduces new keywords, add them to `.cspell/mermaid-terms.txt`. The pre-commit +hook runs CSpell and will otherwise reject the commit. + +## Step 9: Changeset and pull request + +Run `pnpm changeset`, choose the `mermaid` package and a `minor` bump, and write a description +prefixed with `feat:`. + +Open the PR against `develop` and link the issue it resolves. New diagram types are large by +nature, and that is fine, but keep unrelated refactors out of the same branch. + +## Reviewer's checklist + +This is what a reviewer checks. Going through it yourself first is the fastest way to a short +review. + +- [ ] Parser uses Chevrotain, co-located under `diagrams//parser/` +- [ ] Invalid input produces a parse error with position, not a crash +- [ ] `DiagramDefinition` exports parser, db, renderer, and styles +- [ ] Detector in its own file, registered in `diagram-orchestration.ts`, ordered so it does not shadow other diagrams +- [ ] Diagram id reads well as an aria roledescription +- [ ] db holds no module-level state and `clear()` resets everything, including `commonClear()` +- [ ] No imports from other diagrams' folders +- [ ] Renderer applies padding and `useMaxWidth` through `setupViewPortForSVG` +- [ ] Handdrawn mode implemented, or its absence documented +- [ ] `styles.ts` takes theme options, with no hardcoded colors +- [ ] Config options added to `config.schema.yaml` and `config.type.ts` regenerated, never hand-edited +- [ ] Accessibility setters re-exported from `common/commonDb.ts` +- [ ] Unit tests for parser and db, covering invalid input +- [ ] `.mmd` fixtures in `e2e/diagrams//` for visual regression +- [ ] Documentation examples covered by a docs spec +- [ ] Syntax page under `src/docs/syntax/`, with `MERMAID_RELEASE_VERSION` and a sidebar entry +- [ ] Demo page and `demos/index.html` link +- [ ] Example added to `@mermaid-js/examples`, one marked default +- [ ] New keywords added to `.cspell/mermaid-terms.txt` +- [ ] Changeset created (`minor`, `feat:`) +- [ ] PR targets `develop` and links its issue From cdac603af255ab9a7edfff8d5c3f8bd3e1c30dbe Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Mon, 31 Aug 2026 13:19:34 +0200 Subject: [PATCH 2/5] docs: address review on the layout and diagram guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout maker's guide: - Say what "your job is coordinates" means. You never touch the DOM; you are handed measured nodes and hand back a center per node and a polyline per edge, and everything downstream works from those numbers. - Scope the output contract to `runLayoutCore`. The blanket "fill in these fields and nothing else" contradicted the guide's own pipeline, where `prepareLayout` is allowed to rebuild the graph before measurement. - Add a "Layout space" section. Positions are centers, so the left edge is `x - width / 2`; libraries that report a top-left or bottom-left origin need converting in `runLayoutCore`. The plane has no fixed origin and coordinates need not be positive, since the renderer fits the bounding box into the viewBox. - Answer the leaf-versus-group size question directly. Leaf `width` and `height` arrive measured and must not be recalculated; groups are the one place a size is yours to set. - Add "Performance on large diagrams": the flowchart corpus under `e2e/platform/dev-diagrams/performance/`, how to run the dev-explorer profiler over it, how to read the phase table (`↳ ours` against `↳ lib`, and `huge3.mmd` being parse-dominated), why `baseline.json` is a shape to compare against rather than a threshold, and the algorithmic mistakes that actually cost time. - Give the fixture-tree code block a language. New diagram guide: - Document both supported db lifecycles. Fourteen diagrams define `db` as a getter returning a new instance; the rest share one object whose `clear()` resets it. The guide asserted only the first, which is not what the reference implementation does. - "hand-drawn" in prose, `handDrawn` for the config value. --- docs/community/layout-makers-guide.md | 101 ++++++++++++++++-- docs/community/new-diagram.md | 39 ++++++- .../src/docs/community/layout-makers-guide.md | 101 ++++++++++++++++-- .../mermaid/src/docs/community/new-diagram.md | 39 ++++++- 4 files changed, 250 insertions(+), 30 deletions(-) diff --git a/docs/community/layout-makers-guide.md b/docs/community/layout-makers-guide.md index 596a950c382..3bcd3cba41c 100644 --- a/docs/community/layout-makers-guide.md +++ b/docs/community/layout-makers-guide.md @@ -6,7 +6,9 @@ # The Layout Maker's Guide πŸ—ΊοΈ -A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code. Your job is coordinates. +A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code. + +Which means you never touch the DOM. You are handed a graph whose nodes already know how big they are, and you hand back numbers: a center point for every node, a list of points for every edge. Drawing the shapes, painting the strokes, placing the arrowheads, fitting the viewBox β€” all of that is somebody else's code working from your numbers. You are solving a geometry problem, not a rendering one, which is exactly why a layout can be tested without a browser. This guide covers layouts that live inside the Mermaid package, next to `dagre` and the others. The last section explains how to ship the same code as a standalone npm package instead. @@ -25,20 +27,32 @@ interface LayoutData { } ``` -You must fill in these fields and nothing else: +`runLayoutCore`, the stage that holds your algorithm, writes these fields and no others: + +| Field | On | Meaning | +| --------------------------- | ---- | -------------------------------------------------------------------------- | +| `node.x`, `node.y` | Node | Center of the node, in layout space | +| `node.width`, `node.height` | Node | Arrive measured for leaf nodes β€” leave those alone; you set them on groups | +| `edge.points` | Edge | Polyline from source boundary to target boundary, at least 2 points | +| `edge.x`, `edge.y` | Edge | Anchor for the edge label, when the edge has one | + +One stage is allowed to do more. `prepareLayout` runs before measurement and may rebuild the graph β€” replace `data4Layout.nodes` and `data4Layout.edges`, add the synthetic nodes an edge label needs, drop what your algorithm handles another way. "These fields and no others" is a rule about `runLayoutCore`, where the graph is settled and only geometry moves. + +### Layout space + +Positions are centers. `node.x, node.y` is the middle of the node's box, so its left edge is `x - width / 2` and its top edge is `y - height / 2`. Layout libraries disagree about this β€” some report a node's top-left corner, some measure y upward from a bottom-left origin β€” so if you are wrapping one, convert its output in `runLayoutCore` rather than leaving the renderer to guess. Dagre and ELK both report centers, which is why the built-in layouts pass them straight through. -| Field | On | Meaning | -| --------------------------- | ---- | ------------------------------------------------------------------- | -| `node.x`, `node.y` | Node | Center of the node, not its top-left corner | -| `node.width`, `node.height` | Node | Already measured for leaf nodes; you set them for groups | -| `edge.points` | Edge | Polyline from source boundary to target boundary, at least 2 points | -| `edge.x`, `edge.y` | Edge | Anchor for the edge label, when the edge has one | +Beyond that, layout space is a plain plane: x grows to the right, y grows downward. There is no fixed origin and no requirement that coordinates be positive. The renderer takes the bounding box of everything you produced and translates it into the SVG viewBox, so a layout centered on (0, 0) and a layout starting at (0, 0) render identically. Do not spend a pass normalizing coordinates; it is undone immediately. `Node` and `Edge` carry a good deal more than this, all of it defined in `rendering-util/types.ts`. Read the types there rather than working from what a debugger happens to show you: shape, label geometry, styling, and port information all travel on the same objects, and most of it belongs to the renderer rather than to you. Two structural fields are your input, not your output. `node.isGroup` marks a subgraph container, and `node.parentId` names the group a node belongs to. Read them, never rewrite them. -Sizes arrive already measured. The renderer inserts every node into the SVG, calls `getBBox()`, and writes the result back before your algorithm runs. That measurement is the only step that touches the DOM, which is what makes the rest testable. +Leaf sizes arrive already measured, and they are not yours to compute. The renderer inserts every leaf node into the SVG, calls `getBBox()`, and writes `width` and `height` back before your algorithm runs, so a leaf whose size you recalculate or overwrite will have its label spill out of its shape at render time. Read them; treat them as fixed. + +Groups are the exception, and the only place you do set a size. A group's extent is a consequence of where you put its children, so nothing can measure it up front β€” every node with `isGroup` set needs a `width` and `height` from you, big enough to enclose its members and the title band. See [Groups](#groups). + +That one measuring pass is the only step in the whole pipeline that touches the DOM, which is what makes everything after it testable in Node. ## The five stages @@ -267,7 +281,7 @@ When a fixture passes in Node but looks broken on screen, run `validateLayout` o A fixture is a pair of files under `e2e/platform/dev-diagrams/layout-tests/`: -``` +```text layout-tests/ simple-graph.mmd ← real Mermaid source, parsed by the real parser simple-graph.sizes.json ← node dimensions captured from a browser render @@ -486,6 +500,72 @@ The two disagree more often than you would expect, and the disagreement is infor Use the browser to check what the tests cannot see: text that overflows its shape, arrowheads pointing the wrong way, subgraph frames cutting through labels. +## Performance on large diagrams + +A layout that is pleasant on ten nodes can be unusable on a thousand, and the difference does not show up in the fixture sweep β€” DDLT fixtures are small on purpose, so they say nothing about how your algorithm scales. There is a separate corpus for that. + +### The corpus + +`e2e/platform/dev-diagrams/performance/flowcharts/` holds fifteen real flowcharts, anonymized and grouped by size: + +```text +performance/flowcharts/ + medium1.mmd … medium5.mmd ← ~45-50 KB of source each + large1.mmd … large5.mmd ← ~70-75 KB + huge1.mmd … huge5.mmd ← 120-240 KB + baseline.json ← a captured profiler run, for comparison +``` + +The buckets are by source size, and node count does not follow it. `huge1.mmd` is around 2400 nodes with barely fifty edges; `huge2.mmd` is a few hundred nodes with over a thousand edges; `huge3.mmd` is a hundred nodes buried in `classDef` declarations. That spread is deliberate. These are real diagrams people drew, so the shapes that break layout algorithms in practice β€” one node with sixty edges, deeply nested subgraphs, a long thin chain, a wide flat fan β€” appear in the proportions they actually occur rather than the ones a generator would produce. Several carry frontmatter config, `handDrawn` look, and HTML labels, so they exercise the measuring pass as well as the layout. + +### Running the profiler + +Profiling lives in the same dev explorer as everything else in the previous section. + +1. `pnpm dev`, then open `/dev/` at the URL the server printed. +2. Select `performance/flowcharts` in the file tree. +3. In the Profiler panel, tick the layouts to compare, set the scope to **Folder**, choose the number of iterations, and press **Run profile**. + +Each layout is warmed up once and discarded, then every diagram is rendered `iterations` times per layout, with the fastest and slowest run of each series dropped before averaging. The score is total milliseconds across the set, lower is better. **Copy JSON** puts the whole run on the clipboard in the same shape as `baseline.json`. + +One rough edge: the profiler's layout checkboxes come from a hardcoded list in `.esbuild/dev-explorer/diagram-viewer.ts` (`ALL_LAYOUTS`). A new layout will not appear there until you add it, even after it is registered with Mermaid. + +### Reading the table + +The row you care about is rarely the total. Rendering is broken into phases, and only one of them is yours: + +| Phase | What it covers | +| ----------- | ----------------------------------------------------------- | +| `parse` | Diagram text to db | +| `prepare` | Building `LayoutData`, including your `prepareLayout` | +| `measure` | DOM insertion and `getBBox` / `getBoundingClientRect` | +| `layout` | The layout call, split into `↳ lib (external)` and `↳ ours` | +| `paint` | Drawing nodes and edges from your coordinates | +| `serialize` | SVG to string | + +`↳ ours (wrapper)` is your code; `↳ lib (external)` is the third-party library underneath, if you wrap one. A layout that is slow because ELK is slow and a layout that is slow because of your own pass are the same number in the `layout` row and completely different problems one row down. + +`huge3.mmd` makes the point: 1410 ms in `parse` against 1.5 ms in `layout`. Its total is dominated by something you cannot fix from a layout algorithm, and reading totals would send you optimizing the wrong file. + +Watch `measure` too. It is not your phase, but it is downstream of `prepareLayout` β€” every synthetic node you add for an edge label is another element inserted into the DOM and measured, so a `prepareLayout` that is generous with helper nodes shows up as someone else's regression. + +### Comparing against the baseline + +`baseline.json` is a captured run over the same folder β€” dagre and elk, ten iterations, `theme=redux`, `look=neo`, with the capture date in the file. + +Treat the absolute numbers as a record of one machine on one day, not a threshold. They will not reproduce on your hardware, and a CI runner would not reproduce them either. What does carry over is the shape: the relative cost of the phases, which diagrams are layout-dominated and which are parse-dominated, and the ratio between the layouts. Profile your layout in the same run as dagre and elk and compare within that run. + +### What actually goes wrong + +In practice the regressions are algorithmic, not micro-optimizations: + +- A pass over every pair of nodes. Fine at 50 nodes, 1.4 million comparisons at 1200. +- `edges.filter((e) => e.start === node.id)` inside a loop over nodes β€” O(VΒ·E) hidden behind two readable lines. Build the adjacency map once, before the loop. +- Rebuilding a lookup inside an iterative refinement step, so an O(V) cost becomes O(V) per iteration. +- Recursing into subgraphs without memoizing, and revisiting the same subtree once per ancestor. + +Profile before you optimize. Run the corpus, find the diagram where your `↳ ours` row is worst, and read that one β€” the corpus is small enough that the answer is usually one diagram and one loop. + ## Shipping as a separate package An external layout uses the same `render` signature and the same `createCommonLayoutRenderer`. Instead of editing the built-in registry, export a loader array and let the consumer register it: @@ -524,3 +604,4 @@ Package it separately when the layout pulls in a large dependency. Everything el - [ ] Your backend is registered in `ddlt/backends.ts` and your profile in `ddlt-manifest.json` - [ ] The sweep passes and the aggregate score has not dropped - [ ] `.mmd` fixtures under `e2e/diagrams/` cover the layout visually, since layout changes are rendering changes +- [ ] The layout has been profiled against `performance/flowcharts/`, and the `↳ ours` row is understood diff --git a/docs/community/new-diagram.md b/docs/community/new-diagram.md index 023e2f98d43..05f8f7e14b1 100644 --- a/docs/community/new-diagram.md +++ b/docs/community/new-diagram.md @@ -49,9 +49,38 @@ Your diagram must be self-contained. Never import from another diagram's folder. from `diagrams/common/` and from `rendering-util/`, and that is the whole list. Cross-diagram imports create coupling that breaks unrelated diagrams later, so a reviewer will block on this. -Your db gets a fresh instance for every render. Do not keep state in module scope, and make sure -`clear()` resets everything. Two diagrams of the same type on one page will otherwise leak into -each other. +Your db must not carry state from one render into the next. `Diagram.fromText()` reads `db` off +the registered definition and calls `db.clear?.()` before parsing, and there are two supported +ways to satisfy that. + +A getter, which builds a new db on every read, so each render gets its own: + +```ts +export const diagram: DiagramDefinition = { + parser, + get db() { + return new TreeMapDB(); + }, + renderer, + styles, +}; +``` + +Or one shared db whose `clear()` resets every field it owns, plus the shared accessibility state +in `diagrams/common/commonDb.ts`: + +```ts +export const diagram: DiagramDefinition = { parser, db, renderer, styles }; +``` + +Prefer the getter in a new diagram. Isolation is then structural β€” a field added later cannot be +forgotten in `clear()`, which is the way the shared form goes wrong. The use case diagram used as +the reference here takes the shared form, and pays for it by keeping every mutable field on one +`state` object that `clear()` replaces wholesale, rather than resetting fields one by one. + +Either way the rule underneath is the same: all mutable state lives on the db, never in module +scope. Module-level state survives `clear()` in both forms, and two diagrams of the same type on +one page will leak into each other. ## Step 1: Grammar and parsing @@ -114,14 +143,14 @@ import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js setupViewPortForSVG(svg, padding, 'usecaseDiagram', config.useMaxWidth); ``` -Support handdrawn mode if your drawing approach allows it. The config carries a `look`, and +Support hand-drawn mode if your drawing approach allows it. The config carries a `look`, and diagrams check it directly: ```ts const isHandDrawn = look === 'handDrawn'; ``` -If a third party library makes handdrawn output impossible, that is an acceptable answer, but say +If a third party library makes hand-drawn output impossible, that is an acceptable answer, but say so in your diagram's documentation page so users are not left guessing. ## Step 4: Detection and registration diff --git a/packages/mermaid/src/docs/community/layout-makers-guide.md b/packages/mermaid/src/docs/community/layout-makers-guide.md index 5b78a873188..5362299af78 100644 --- a/packages/mermaid/src/docs/community/layout-makers-guide.md +++ b/packages/mermaid/src/docs/community/layout-makers-guide.md @@ -1,6 +1,8 @@ # The Layout Maker's Guide πŸ—ΊοΈ -A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code. Your job is coordinates. +A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code. + +Which means you never touch the DOM. You are handed a graph whose nodes already know how big they are, and you hand back numbers: a center point for every node, a list of points for every edge. Drawing the shapes, painting the strokes, placing the arrowheads, fitting the viewBox β€” all of that is somebody else's code working from your numbers. You are solving a geometry problem, not a rendering one, which is exactly why a layout can be tested without a browser. This guide covers layouts that live inside the Mermaid package, next to `dagre` and the others. The last section explains how to ship the same code as a standalone npm package instead. @@ -19,20 +21,32 @@ interface LayoutData { } ``` -You must fill in these fields and nothing else: +`runLayoutCore`, the stage that holds your algorithm, writes these fields and no others: + +| Field | On | Meaning | +| --------------------------- | ---- | -------------------------------------------------------------------------- | +| `node.x`, `node.y` | Node | Center of the node, in layout space | +| `node.width`, `node.height` | Node | Arrive measured for leaf nodes β€” leave those alone; you set them on groups | +| `edge.points` | Edge | Polyline from source boundary to target boundary, at least 2 points | +| `edge.x`, `edge.y` | Edge | Anchor for the edge label, when the edge has one | + +One stage is allowed to do more. `prepareLayout` runs before measurement and may rebuild the graph β€” replace `data4Layout.nodes` and `data4Layout.edges`, add the synthetic nodes an edge label needs, drop what your algorithm handles another way. "These fields and no others" is a rule about `runLayoutCore`, where the graph is settled and only geometry moves. + +### Layout space + +Positions are centers. `node.x, node.y` is the middle of the node's box, so its left edge is `x - width / 2` and its top edge is `y - height / 2`. Layout libraries disagree about this β€” some report a node's top-left corner, some measure y upward from a bottom-left origin β€” so if you are wrapping one, convert its output in `runLayoutCore` rather than leaving the renderer to guess. Dagre and ELK both report centers, which is why the built-in layouts pass them straight through. -| Field | On | Meaning | -| --------------------------- | ---- | ------------------------------------------------------------------- | -| `node.x`, `node.y` | Node | Center of the node, not its top-left corner | -| `node.width`, `node.height` | Node | Already measured for leaf nodes; you set them for groups | -| `edge.points` | Edge | Polyline from source boundary to target boundary, at least 2 points | -| `edge.x`, `edge.y` | Edge | Anchor for the edge label, when the edge has one | +Beyond that, layout space is a plain plane: x grows to the right, y grows downward. There is no fixed origin and no requirement that coordinates be positive. The renderer takes the bounding box of everything you produced and translates it into the SVG viewBox, so a layout centered on (0, 0) and a layout starting at (0, 0) render identically. Do not spend a pass normalizing coordinates; it is undone immediately. `Node` and `Edge` carry a good deal more than this, all of it defined in `rendering-util/types.ts`. Read the types there rather than working from what a debugger happens to show you: shape, label geometry, styling, and port information all travel on the same objects, and most of it belongs to the renderer rather than to you. Two structural fields are your input, not your output. `node.isGroup` marks a subgraph container, and `node.parentId` names the group a node belongs to. Read them, never rewrite them. -Sizes arrive already measured. The renderer inserts every node into the SVG, calls `getBBox()`, and writes the result back before your algorithm runs. That measurement is the only step that touches the DOM, which is what makes the rest testable. +Leaf sizes arrive already measured, and they are not yours to compute. The renderer inserts every leaf node into the SVG, calls `getBBox()`, and writes `width` and `height` back before your algorithm runs, so a leaf whose size you recalculate or overwrite will have its label spill out of its shape at render time. Read them; treat them as fixed. + +Groups are the exception, and the only place you do set a size. A group's extent is a consequence of where you put its children, so nothing can measure it up front β€” every node with `isGroup` set needs a `width` and `height` from you, big enough to enclose its members and the title band. See [Groups](#groups). + +That one measuring pass is the only step in the whole pipeline that touches the DOM, which is what makes everything after it testable in Node. ## The five stages @@ -261,7 +275,7 @@ When a fixture passes in Node but looks broken on screen, run `validateLayout` o A fixture is a pair of files under `e2e/platform/dev-diagrams/layout-tests/`: -``` +```text layout-tests/ simple-graph.mmd ← real Mermaid source, parsed by the real parser simple-graph.sizes.json ← node dimensions captured from a browser render @@ -480,6 +494,72 @@ The two disagree more often than you would expect, and the disagreement is infor Use the browser to check what the tests cannot see: text that overflows its shape, arrowheads pointing the wrong way, subgraph frames cutting through labels. +## Performance on large diagrams + +A layout that is pleasant on ten nodes can be unusable on a thousand, and the difference does not show up in the fixture sweep β€” DDLT fixtures are small on purpose, so they say nothing about how your algorithm scales. There is a separate corpus for that. + +### The corpus + +`e2e/platform/dev-diagrams/performance/flowcharts/` holds fifteen real flowcharts, anonymized and grouped by size: + +```text +performance/flowcharts/ + medium1.mmd … medium5.mmd ← ~45-50 KB of source each + large1.mmd … large5.mmd ← ~70-75 KB + huge1.mmd … huge5.mmd ← 120-240 KB + baseline.json ← a captured profiler run, for comparison +``` + +The buckets are by source size, and node count does not follow it. `huge1.mmd` is around 2400 nodes with barely fifty edges; `huge2.mmd` is a few hundred nodes with over a thousand edges; `huge3.mmd` is a hundred nodes buried in `classDef` declarations. That spread is deliberate. These are real diagrams people drew, so the shapes that break layout algorithms in practice β€” one node with sixty edges, deeply nested subgraphs, a long thin chain, a wide flat fan β€” appear in the proportions they actually occur rather than the ones a generator would produce. Several carry frontmatter config, `handDrawn` look, and HTML labels, so they exercise the measuring pass as well as the layout. + +### Running the profiler + +Profiling lives in the same dev explorer as everything else in the previous section. + +1. `pnpm dev`, then open `/dev/` at the URL the server printed. +2. Select `performance/flowcharts` in the file tree. +3. In the Profiler panel, tick the layouts to compare, set the scope to **Folder**, choose the number of iterations, and press **Run profile**. + +Each layout is warmed up once and discarded, then every diagram is rendered `iterations` times per layout, with the fastest and slowest run of each series dropped before averaging. The score is total milliseconds across the set, lower is better. **Copy JSON** puts the whole run on the clipboard in the same shape as `baseline.json`. + +One rough edge: the profiler's layout checkboxes come from a hardcoded list in `.esbuild/dev-explorer/diagram-viewer.ts` (`ALL_LAYOUTS`). A new layout will not appear there until you add it, even after it is registered with Mermaid. + +### Reading the table + +The row you care about is rarely the total. Rendering is broken into phases, and only one of them is yours: + +| Phase | What it covers | +| ----------- | ----------------------------------------------------------- | +| `parse` | Diagram text to db | +| `prepare` | Building `LayoutData`, including your `prepareLayout` | +| `measure` | DOM insertion and `getBBox` / `getBoundingClientRect` | +| `layout` | The layout call, split into `↳ lib (external)` and `↳ ours` | +| `paint` | Drawing nodes and edges from your coordinates | +| `serialize` | SVG to string | + +`↳ ours (wrapper)` is your code; `↳ lib (external)` is the third-party library underneath, if you wrap one. A layout that is slow because ELK is slow and a layout that is slow because of your own pass are the same number in the `layout` row and completely different problems one row down. + +`huge3.mmd` makes the point: 1410 ms in `parse` against 1.5 ms in `layout`. Its total is dominated by something you cannot fix from a layout algorithm, and reading totals would send you optimizing the wrong file. + +Watch `measure` too. It is not your phase, but it is downstream of `prepareLayout` β€” every synthetic node you add for an edge label is another element inserted into the DOM and measured, so a `prepareLayout` that is generous with helper nodes shows up as someone else's regression. + +### Comparing against the baseline + +`baseline.json` is a captured run over the same folder β€” dagre and elk, ten iterations, `theme=redux`, `look=neo`, with the capture date in the file. + +Treat the absolute numbers as a record of one machine on one day, not a threshold. They will not reproduce on your hardware, and a CI runner would not reproduce them either. What does carry over is the shape: the relative cost of the phases, which diagrams are layout-dominated and which are parse-dominated, and the ratio between the layouts. Profile your layout in the same run as dagre and elk and compare within that run. + +### What actually goes wrong + +In practice the regressions are algorithmic, not micro-optimizations: + +- A pass over every pair of nodes. Fine at 50 nodes, 1.4 million comparisons at 1200. +- `edges.filter((e) => e.start === node.id)` inside a loop over nodes β€” O(VΒ·E) hidden behind two readable lines. Build the adjacency map once, before the loop. +- Rebuilding a lookup inside an iterative refinement step, so an O(V) cost becomes O(V) per iteration. +- Recursing into subgraphs without memoizing, and revisiting the same subtree once per ancestor. + +Profile before you optimize. Run the corpus, find the diagram where your `↳ ours` row is worst, and read that one β€” the corpus is small enough that the answer is usually one diagram and one loop. + ## Shipping as a separate package An external layout uses the same `render` signature and the same `createCommonLayoutRenderer`. Instead of editing the built-in registry, export a loader array and let the consumer register it: @@ -518,3 +598,4 @@ Package it separately when the layout pulls in a large dependency. Everything el - [ ] Your backend is registered in `ddlt/backends.ts` and your profile in `ddlt-manifest.json` - [ ] The sweep passes and the aggregate score has not dropped - [ ] `.mmd` fixtures under `e2e/diagrams/` cover the layout visually, since layout changes are rendering changes +- [ ] The layout has been profiled against `performance/flowcharts/`, and the `↳ ours` row is understood diff --git a/packages/mermaid/src/docs/community/new-diagram.md b/packages/mermaid/src/docs/community/new-diagram.md index 7d277faf3d9..fafffb72347 100644 --- a/packages/mermaid/src/docs/community/new-diagram.md +++ b/packages/mermaid/src/docs/community/new-diagram.md @@ -43,9 +43,38 @@ Your diagram must be self-contained. Never import from another diagram's folder. from `diagrams/common/` and from `rendering-util/`, and that is the whole list. Cross-diagram imports create coupling that breaks unrelated diagrams later, so a reviewer will block on this. -Your db gets a fresh instance for every render. Do not keep state in module scope, and make sure -`clear()` resets everything. Two diagrams of the same type on one page will otherwise leak into -each other. +Your db must not carry state from one render into the next. `Diagram.fromText()` reads `db` off +the registered definition and calls `db.clear?.()` before parsing, and there are two supported +ways to satisfy that. + +A getter, which builds a new db on every read, so each render gets its own: + +```ts +export const diagram: DiagramDefinition = { + parser, + get db() { + return new TreeMapDB(); + }, + renderer, + styles, +}; +``` + +Or one shared db whose `clear()` resets every field it owns, plus the shared accessibility state +in `diagrams/common/commonDb.ts`: + +```ts +export const diagram: DiagramDefinition = { parser, db, renderer, styles }; +``` + +Prefer the getter in a new diagram. Isolation is then structural β€” a field added later cannot be +forgotten in `clear()`, which is the way the shared form goes wrong. The use case diagram used as +the reference here takes the shared form, and pays for it by keeping every mutable field on one +`state` object that `clear()` replaces wholesale, rather than resetting fields one by one. + +Either way the rule underneath is the same: all mutable state lives on the db, never in module +scope. Module-level state survives `clear()` in both forms, and two diagrams of the same type on +one page will leak into each other. ## Step 1: Grammar and parsing @@ -108,14 +137,14 @@ import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js setupViewPortForSVG(svg, padding, 'usecaseDiagram', config.useMaxWidth); ``` -Support handdrawn mode if your drawing approach allows it. The config carries a `look`, and +Support hand-drawn mode if your drawing approach allows it. The config carries a `look`, and diagrams check it directly: ```ts const isHandDrawn = look === 'handDrawn'; ``` -If a third party library makes handdrawn output impossible, that is an acceptable answer, but say +If a third party library makes hand-drawn output impossible, that is an acceptable answer, but say so in your diagram's documentation page so users are not left guessing. ## Step 4: Detection and registration From 20d4a63bd3cbc27c81864a97993ae266ab051a58 Mon Sep 17 00:00:00 2001 From: Per Brolin Date: Tue, 1 Sep 2026 12:16:30 +0200 Subject: [PATCH 3/5] Added developer tests for state diagrams --- .../state-diagram/1-simple-state-diagram.mmd | 3 +++ .../diagrams/state-diagram/10-state-with-a-note.mmd | 6 ++++++ .../state-diagram/11-transitions-with-labels.mmd | 8 ++++++++ .../state-diagram/2-simple-state-diagram-v2.mmd | 4 ++++ .../3-clickable-state-with-tooltip.mmd | 3 +++ .../state-diagram/4-simple-two-state-chain.mmd | 4 ++++ .../state-diagram/5-basic-states-all-pairs.mmd | 13 +++++++++++++ .../state-diagram/6-states-with-descriptions.mmd | 13 +++++++++++++ .../7-special-state-types-fork-join.mmd | 11 +++++++++++ .../diagrams/state-diagram/8-composite-states.mmd | 10 ++++++++++ .../diagrams/state-diagram/9-concurrent-states.mmd | 11 +++++++++++ 11 files changed, 86 insertions(+) create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/1-simple-state-diagram.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/10-state-with-a-note.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/11-transitions-with-labels.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/2-simple-state-diagram-v2.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/3-clickable-state-with-tooltip.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/4-simple-two-state-chain.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/5-basic-states-all-pairs.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/6-states-with-descriptions.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/7-special-state-types-fork-join.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/8-composite-states.mmd create mode 100644 e2e/platform/dev-diagrams/diagrams/state-diagram/9-concurrent-states.mmd diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/1-simple-state-diagram.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/1-simple-state-diagram.mmd new file mode 100644 index 00000000000..5765f1cf4f0 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/1-simple-state-diagram.mmd @@ -0,0 +1,3 @@ +stateDiagram + [*] --> State1 + State1 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/10-state-with-a-note.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/10-state-with-a-note.mmd new file mode 100644 index 00000000000..490be33c57f --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/10-state-with-a-note.mmd @@ -0,0 +1,6 @@ +stateDiagram + State1: The state with a note + note right of State1 + Important information! You can write + notes. + end note diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/11-transitions-with-labels.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/11-transitions-with-labels.mmd new file mode 100644 index 00000000000..17a520ed4a2 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/11-transitions-with-labels.mmd @@ -0,0 +1,8 @@ +stateDiagram + [*] --> State1 + State1 --> State2 : Transition 1 + State1 --> State3 : Transition 2 + State1 --> State4 : Transition 3 + State1 --> State5 : Transition 4 + State2 --> State3 : Transition 5 + State1 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/2-simple-state-diagram-v2.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/2-simple-state-diagram-v2.mmd new file mode 100644 index 00000000000..d4f3044156d --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/2-simple-state-diagram-v2.mmd @@ -0,0 +1,4 @@ +stateDiagram-v2 + + [*] --> State1 + State1 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/3-clickable-state-with-tooltip.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/3-clickable-state-with-tooltip.mmd new file mode 100644 index 00000000000..36e09598dc5 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/3-clickable-state-with-tooltip.mmd @@ -0,0 +1,3 @@ +stateDiagram-v2 + A: Google + click A "https://google.com" "Visit Google" diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/4-simple-two-state-chain.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/4-simple-two-state-chain.mmd new file mode 100644 index 00000000000..0be030f1758 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/4-simple-two-state-chain.mmd @@ -0,0 +1,4 @@ +stateDiagram-v2 + [*] --> State1 + State1 --> State2 + State2 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/5-basic-states-all-pairs.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/5-basic-states-all-pairs.mmd new file mode 100644 index 00000000000..21564f82714 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/5-basic-states-all-pairs.mmd @@ -0,0 +1,13 @@ +stateDiagram-v2 + [*] --> State1 + State1 --> State2 + State1 --> State3 + State1 --> State4 + State1 --> State5 + State2 --> State3 + State2 --> State4 + State2 --> State5 + State3 --> State4 + State3 --> State5 + State4 --> State5 + State5 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/6-states-with-descriptions.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/6-states-with-descriptions.mmd new file mode 100644 index 00000000000..02d4e57028a --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/6-states-with-descriptions.mmd @@ -0,0 +1,13 @@ +stateDiagram-v2 + [*] --> State1 + State1 --> State2 + State1 --> State3 + State1 --> State4 + State2 --> State3 + State2 --> State4 + State3 --> State4 + state State1: Description 1 + state State2: Description 2 + state State3: Description 3 + state State4: Description 4 + State4 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/7-special-state-types-fork-join.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/7-special-state-types-fork-join.mmd new file mode 100644 index 00000000000..24e407332bb --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/7-special-state-types-fork-join.mmd @@ -0,0 +1,11 @@ +stateDiagram-v2 + state fork_state <> + [*] --> fork_state + fork_state --> State2 + fork_state --> State3 + + state join_state <> + State2 --> join_state + State3 --> join_state + join_state --> State4 + State4 --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/8-composite-states.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/8-composite-states.mmd new file mode 100644 index 00000000000..b8635ae638e --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/8-composite-states.mmd @@ -0,0 +1,10 @@ +stateDiagram-v2 + [*] --> Active + state Active { + [*] --> Running + Running --> Paused + Paused --> Running + Running --> [*] + } + Active --> Inactive + Inactive --> [*] diff --git a/e2e/platform/dev-diagrams/diagrams/state-diagram/9-concurrent-states.mmd b/e2e/platform/dev-diagrams/diagrams/state-diagram/9-concurrent-states.mmd new file mode 100644 index 00000000000..76d5da3e5b9 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/state-diagram/9-concurrent-states.mmd @@ -0,0 +1,11 @@ +stateDiagram-v2 + [*] --> Active + state Active { + [*] --> NumLockOff + NumLockOff --> NumLockOn + NumLockOn --> NumLockOff + -- + [*] --> CapsLockOff + CapsLockOff --> CapsLockOn + CapsLockOn --> CapsLockOff + } From 012e1f78d36bf2b523613d4e3c273892df988fce Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Tue, 1 Sep 2026 12:54:02 +0200 Subject: [PATCH 4/5] fix(elk): center edges attached to small nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ELK reserves a 12px ports-surrounding margin at both ends of a node side before distributing edge anchors along it. On a side shorter than 24px β€” a start/end state circle is 14px across β€” the usable span is negative and ELK's clamping parks the anchor off-center, so the only edge in '[*] --> [*]' attached 3px off the dot's center. No node-level option can override this: the spacing is only read per hierarchy level. Drop such anchors when applying routed sections and let the edge aim at the node center instead; the border clip then lands it dead center, the same way the dagre pipeline attaches edges. The check is side-specific so fork/join bars keep their spread anchors along the long side. --- .changeset/elk-small-node-edge-centering.md | 5 +++ .../src/__tests__/render.spec.ts | 41 +++++++++++++++++++ packages/mermaid-layout-elk/src/render.ts | 31 ++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .changeset/elk-small-node-edge-centering.md diff --git a/.changeset/elk-small-node-edge-centering.md b/.changeset/elk-small-node-edge-centering.md new file mode 100644 index 00000000000..557363d5cd4 --- /dev/null +++ b/.changeset/elk-small-node-edge-centering.md @@ -0,0 +1,5 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: center edges attached to small nodes such as start/end state circles. ELK's ports-surrounding margin exceeded the side length of nodes narrower than 24px, parking the edge anchor off-center; such anchors are now discarded so the edge aims at the node center. diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index fcb387bca37..ba45aaf166d 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -604,6 +604,47 @@ describe('runElkLayoutCore', () => { }); }); +describe('small-node edge anchoring', () => { + // A start/end state circle is 14px across β€” smaller than twice the 12px + // ports-surrounding margin β€” so ELK's anchor for it lands off-centre and is + // discarded in favour of aiming at the node centre. The whole route must + // then run down the shared centre line. + it('centres a single edge between two start/end state circles', async () => { + const data = { + direction: 'TB', + config: { elk: {} }, + nodes: [ + { + id: 'root_start', + isGroup: false, + width: 14, + height: 14, + label: 'root_start', + shape: 'stateStart', + }, + { + id: 'root_end', + isGroup: false, + width: 14, + height: 14, + label: 'root_end', + shape: 'stateEnd', + }, + ], + edges: [{ id: 'edge0', start: 'root_start', end: 'root_end', type: 'arrow_barb' }], + } as any; + + await runElkLayoutCore(data, elkRenderContext); + + const start = data.nodes.find((node: any) => node.id === 'root_start'); + const edge = data.edges[0]; + expect(edge.points.length).toBeGreaterThanOrEqual(2); + for (const point of edge.points) { + expect(point.x).toBeCloseTo(start.x, 3); + } + }); +}); + describe('ensureEndMarkerSegmentLength', () => { const log = { debug: () => undefined }; const circleBounds = { diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 485347b4ebe..9b51e7b31c8 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -149,6 +149,7 @@ const DEFAULT_NODE_PLACEMENT_ALIGNMENT = 'NONE'; * takes one. */ const PORTS_SURROUNDING = '[top=12,left=12,bottom=12,right=12]'; +const PORTS_SURROUNDING_MARGIN = 12; /** Padding between a subgraph frame and its children. ELK's own default is 12. */ const SUBGRAPH_PADDING = 24; /** @@ -1711,10 +1712,16 @@ function applyElkEdgeLayout( endNode.y = endNode.offset!.posY + endNode.height! / 2; if (startNode.shape !== 'rect33') { + if (points.length > 1 && anchorOnDegenerateSide(startNode, points[0])) { + points.shift(); + } points.unshift({ x: startNode.x, y: startNode.y }); } if (endNode.shape !== 'rect33') { + if (points.length > 1 && anchorOnDegenerateSide(endNode, points[points.length - 1])) { + points.pop(); + } points.push({ x: endNode.x, y: endNode.y }); } @@ -1739,6 +1746,30 @@ function applyElkEdgeLayout( } } +/** + * ELK reserves `PORTS_SURROUNDING_MARGIN` at both ends of a node side before + * distributing edge anchors along it (see `PORTS_SURROUNDING`). On a side + * shorter than twice that margin the usable span is negative, and ELK's + * clamping parks the anchor off-centre β€” a 14px start/end state circle got + * its only edge attached 3px off the dot's centre, and no node-level option + * overrides it (the spacing is only read per hierarchy level). Such an anchor + * carries no information, so the caller drops it and lets the edge aim at the + * node centre instead; the border clip then lands it dead centre, the same + * way the dagre pipeline attaches edges. + */ +function anchorOnDegenerateSide(node: NodeWithVertex, anchor: P): boolean { + const width = node.width ?? 0; + const height = node.height ?? 0; + const top = node.offset!.posY; + const bottom = top + height; + const tol = 0.5; + // An anchor on the top or bottom border spreads along the width; one on the + // left or right border spreads along the height. + const alongWidth = Math.abs(anchor.y - top) <= tol || Math.abs(anchor.y - bottom) <= tol; + const side = alongWidth ? width : height; + return side < 2 * PORTS_SURROUNDING_MARGIN; +} + function createEdgePointsFromSection(section: any, offset: { x: number; y: number }): P[] { const src = section.startPoint; const dest = section.endPoint; From 5ff81b444e1a3060765926c21789b7fa7b5ecf48 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Tue, 1 Sep 2026 13:33:41 +0200 Subject: [PATCH 5/5] =?UTF-8?q?test(elk):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20e2e=20fixture=20and=20fork/join=20anchor=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a state+ELK snapshot fixture for the start/end centering case so the rendered result is guarded by the visual suite, and a unit test pinning the side-specific part of the anchor drop: a wide, thin fork/join bar must keep two spread anchors instead of funnelling edges to its centre (verified to fail when the side check is replaced with min(width,height)). Also derive the portsSurrounding option string from the margin constant so the two cannot drift apart, and document the border tolerance. --- .../elk/elk-start-end-edge-centering.mmd | 6 ++++ .../src/__tests__/render.spec.ts | 33 +++++++++++++++++++ packages/mermaid-layout-elk/src/render.ts | 10 ++++-- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 e2e/diagrams/state-diagram-v2/elk/elk-start-end-edge-centering.mmd diff --git a/e2e/diagrams/state-diagram-v2/elk/elk-start-end-edge-centering.mmd b/e2e/diagrams/state-diagram-v2/elk/elk-start-end-edge-centering.mmd new file mode 100644 index 00000000000..d3099f4ad88 --- /dev/null +++ b/e2e/diagrams/state-diagram-v2/elk/elk-start-end-edge-centering.mmd @@ -0,0 +1,6 @@ +--- +config: + layout: elk +--- +stateDiagram-v2 + [*] --> [*] diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index ba45aaf166d..40a54473ae9 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -643,6 +643,39 @@ describe('small-node edge anchoring', () => { expect(point.x).toBeCloseTo(start.x, 3); } }); + + // The drop is side-specific: a fork/join bar is thin but long, and the side + // its anchors spread along (the width, for a top/bottom attachment) is well + // above the margin threshold. Its anchors carry real information β€” two + // incoming edges must keep two distinct attachment points instead of being + // funnelled to the bar's centre. + it('keeps spread anchors on a wide, thin fork/join bar', async () => { + const data = { + direction: 'TB', + config: { elk: {} }, + nodes: [ + { id: 'a', isGroup: false, width: 40, height: 20, label: 'a', shape: 'rect' }, + { id: 'b', isGroup: false, width: 40, height: 20, label: 'b', shape: 'rect' }, + { id: 'bar', isGroup: false, width: 120, height: 10, label: 'bar', shape: 'forkJoin' }, + ], + edges: [ + { id: 'e1', start: 'a', end: 'bar', type: 'arrow_point' }, + { id: 'e2', start: 'b', end: 'bar', type: 'arrow_point' }, + ], + } as any; + + await runElkLayoutCore(data, elkRenderContext); + + const bar = data.nodes.find((node: any) => node.id === 'bar'); + const arrivalXs = data.edges.map((edge: any) => edge.points.at(-1).x); + expect(Math.abs(arrivalXs[0] - arrivalXs[1])).toBeGreaterThan(1); + // ELK spreads the two anchors ~28px either side of the bar's centre. An + // edge whose anchor was wrongly dropped aims at the centre instead and + // arrives within ~5px of it, so require real clearance from the centre. + for (const x of arrivalXs) { + expect(Math.abs(x - bar.x)).toBeGreaterThan(10); + } + }); }); describe('ensureEndMarkerSegmentLength', () => { diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 9b51e7b31c8..c4ca5658193 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -145,11 +145,13 @@ 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. + * placed on a corner. `anchorOnDegenerateSide` treats a side shorter than twice + * this as having no usable anchor span, so the option string below is built + * from it β€” the two must not be able to disagree. */ -const PORTS_SURROUNDING = '[top=12,left=12,bottom=12,right=12]'; const PORTS_SURROUNDING_MARGIN = 12; +/** The margin spelled as an ELK margin, because `spacing.portsSurrounding` takes one. */ +const PORTS_SURROUNDING = `[top=${PORTS_SURROUNDING_MARGIN},left=${PORTS_SURROUNDING_MARGIN},bottom=${PORTS_SURROUNDING_MARGIN},right=${PORTS_SURROUNDING_MARGIN}]`; /** Padding between a subgraph frame and its children. ELK's own default is 12. */ const SUBGRAPH_PADDING = 24; /** @@ -1762,6 +1764,8 @@ function anchorOnDegenerateSide(node: NodeWithVertex, anchor: P): boolean { const height = node.height ?? 0; const top = node.offset!.posY; const bottom = top + height; + // ELK puts the anchor exactly on the border; the slack only absorbs float + // error from the offset arithmetic above. Same tolerance `onBorder` uses. const tol = 0.5; // An anchor on the top or bottom border spreads along the width; one on the // left or right border spreads along the height.