diff --git a/.changeset/elk-even-subgraph-frames.md b/.changeset/elk-even-subgraph-frames.md new file mode 100644 index 00000000000..27eb481fa34 --- /dev/null +++ b/.changeset/elk-even-subgraph-frames.md @@ -0,0 +1,15 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: draw ELK subgraph frames an even distance from their contents. + +A subgraph could sit 76px from its nodes on one side and 24px on the other, with nothing visible in the gap. ELK sizes a container around everything it put inside, edges included, and an edge that runs against the flow of the layout is routed back around the outside — so a group holding one grew on whichever side that edge left by, and a group without one did not. + +The lane is real and the edge still needs it, so the space is not reclaimed. What changes is that the frame is no longer drawn around it: the frame is pulled in to an even distance from the group's own children, and the edge keeps its lane just outside, which is what an edge routed around a group should look like anyway. + +The top is left as ELK set it, since it carries the subgraph's title strip and there is no way to tell how much of that padding is the title and how much is spare. + +A frame still holds the lanes that genuinely belong to it. An edge with both endpoints inside a group never leaves it, so its lane is part of that group's interior and the frame stays drawn around it — which matters for nested groups, where a lane routed around an inner group sits inside the outer one. + +**Subgraphs render tighter, and groups that used to be visibly lopsided are now even.** diff --git a/.changeset/elk-layout-presets.md b/.changeset/elk-layout-presets.md new file mode 100644 index 00000000000..55af18a01c8 --- /dev/null +++ b/.changeset/elk-layout-presets.md @@ -0,0 +1,29 @@ +--- +'@mermaid-js/layout-elk': minor +--- + +feat: `elk.preset` picks a named combination of the options that decide where nodes end up. + +Three options settle node positions, and they sit in different phases of the layout: which layer a node lands in, where it goes within that layer, and which edges get reversed to make the graph acyclic. Choosing them well means knowing all three interact; `preset` names the combinations worth using. + +- `default` — network simplex layering and placement with depth-first cycle breaking at the top level; subgraph contents are placed with Brandes-Koepf. Depth-first breaking gives shorter back edges on graphs that loop. +- `legacy` — reproduces what earlier versions actually rendered: Brandes-Koepf placement with ELK's own greedy cycle breaking. +- `modelOrder` — as `default`, but breaks cycles by greedy model order, which disturbs declaration order least at the cost of longer back edges. +- `depthFirst` — a name for what `default` already is, so a diagram can say depth-first rather than rely on the default staying put. + +```yaml +--- +config: + layout: elk + elk: + preset: legacy +--- +``` + +Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one option and leaves the rest in place, so a preset is a starting point rather than a lock. + +**Node placement changes from `BRANDES_KOEPF` to `NETWORK_SIMPLEX`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour, and is the single switch for it — this is the net change against the last release, measured from what shipped rather than from any intermediate state. + +Subgraph contents keep `BRANDES_KOEPF`, which is deliberately not the root's strategy: network simplex inside a frame produced routes that left the subgraph on its bounding-box corner. The two sides are tuned independently, so `nodePlacementStrategy` set explicitly still applies to both. + +Note that `legacy` uses `GREEDY` cycle breaking rather than the `GREEDY_MODEL_ORDER` the schema previously advertised. That default was declared in the schema but never listed in the shipped defaults, so it reached ELK as undefined and ELK's own default applied — `legacy` reproduces what was rendered, not what was documented. diff --git a/.changeset/elk-line-hops.md b/.changeset/elk-line-hops.md new file mode 100644 index 00000000000..f0fbfa3a48e --- /dev/null +++ b/.changeset/elk-line-hops.md @@ -0,0 +1,20 @@ +--- +'@mermaid-js/layout-elk': minor +--- + +feat: draw line hops where ELK edges cross, controlled by `elk.lineHops`. + +Where two edges cross, the one that gives way is drawn with a small arc (or a visible gap) so it is clear which line passes over which. On by default; set `elk.lineHops: false` to draw plain crossings, or `'gap'` to use gaps instead of arcs. + +```yaml +--- +config: + layout: elk + elk: + lineHops: gap +--- +``` + +The crossing detection and both styles already existed and were used by swimlanes — this registers the `afterPaint` hook that lets ELK use them. An edge that takes a hop loses its corner rounding on that segment, which is the trade for a readable crossing; curved edges are skipped rather than rewritten, to avoid corrupting their geometry. + +**Existing ELK diagrams with crossing edges will render differently.** diff --git a/.changeset/elk-non-rect-attachment.md b/.changeset/elk-non-rect-attachment.md new file mode 100644 index 00000000000..dc0726adeb1 --- /dev/null +++ b/.changeset/elk-non-rect-attachment.md @@ -0,0 +1,9 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: edges leave diamonds, stadiums and other non-rectangular shapes without kinking. + +ELK routes to ports on a node's bounding box and always leaves one perpendicular to the side it sits on. For a rectangle that port is the attachment point; for anything else the outline is inside the box, so the attachment has to move inwards — and the direction it moves in decides whether the edge stays orthogonal. + +It used to move along the ray from the node's centre, which lands on the outline at a different offset along the side than the port ELK chose, so the opening segment came out diagonal. The attachment now walks the outline along the edge's own departure axis, staying collinear with ELK's stub: the edge leaves the outline, crosses the box, and carries on in one straight line. Rectangular nodes are unaffected. diff --git a/.changeset/elk-port-corner-spacing.md b/.changeset/elk-port-corner-spacing.md new file mode 100644 index 00000000000..b080bc19e89 --- /dev/null +++ b/.changeset/elk-port-corner-spacing.md @@ -0,0 +1,21 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: an edge no longer leaves a subgraph from the frame's corner. + +`elk.spacing.portsSurrounding` was left at ELK's default of `0`, which permits a +port to sit exactly on a node's corner. A corner is the one boundary point with +no side to leave from, so the edge came out of the vertex and then ran ALONG the +frame's own edge before turning away from it. Subgraphs showed it first, because +an edge that crosses a subgraph boundary attaches to the frame rather than to a +node inside it, and a frame is large enough for the result to be obvious. + +A margin is now reserved at the ends of every side, so ELK keeps ports off the +corners itself rather than the renderer correcting them afterwards. Over the +`elk-edge-cases` corpus this takes the fixtures with a corner endpoint from 8 of +30 down to 3. + +The value is 12, chosen by measurement: it is the smallest that clears the +corner on that corpus. It is not a free parameter — 30 was tried and reorders +layers. diff --git a/.changeset/elk-straighten-terminal-jogs.md b/.changeset/elk-straighten-terminal-jogs.md new file mode 100644 index 00000000000..bf430973618 --- /dev/null +++ b/.changeset/elk-straighten-terminal-jogs.md @@ -0,0 +1,11 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: an edge no longer leaves a node with a tiny kink. + +ELK spreads an edge's port evenly along a node's side, then routes the edge down a channel whose row rarely lines up with that port exactly. The leftover is a staircase of a few pixels right at the border: leave the port, run a short distance, step perpendicular onto the channel, carry on. With rounded corners the two micro-bends sit on top of each other and read as a glitch — one edge in the sample corpus stepped 3.25px and rendered as two quadratic curves with a zero-length segment between them. + +The step is now removed by moving the channel onto the port's row, so the edge draws as one straight line and **both ports stay exactly where the layout put them** — sliding an attachment along a node's border leaves a node whose other edges are still evenly spread looking lopsided. Only a step next to a node is touched, and only when it is small and the edge continues the same way afterwards, so a real turn is never collapsed. An edge is left alone entirely when moving its run would drag the far port, or would buy a crossing. + +Set `elk.straightenEdges: false` to keep the previous behaviour. diff --git a/.changeset/elk-subgraph-spacing-split.md b/.changeset/elk-subgraph-spacing-split.md new file mode 100644 index 00000000000..3e03430ff4e --- /dev/null +++ b/.changeset/elk-subgraph-spacing-split.md @@ -0,0 +1,17 @@ +--- +'@mermaid-js/layout-elk': patch +--- + +fix: stop ELK subgraphs padding one side more than the other. + +A subgraph could end up with far more space on one side than the other for no reason a reader could see — 74px on the right of one group against 24px everywhere else. The extra space was a routing lane held open for an edge that runs against the flow of the layout and has to be routed back around, and its width came from `spacing.baseValue`. + +That base value was doing two jobs at once. Every unset ELK spacing derives from it, so it had to stay large enough that an edge got a straight run before the node it enters — below about 40 the approach came out shorter than the arrowhead and the turn read as happening underneath it. But an edge routed down the inside of a frame claims a lane the same width, so paying for the approach out of the base value also pushed groups clear of their own borders. + +The two are now bought separately. The base value drops to 24, and the approach run, node separation and edge separation are set explicitly, so edges keep the run they had without the frame paying for it. + +`elk.layered.spacing.edgeNodeBetweenLayers` is the key that buys the approach. An earlier attempt used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is edge-to-edge and a different quantity, and a note in the source concluded from it that ELK ignored edge-node spacing "in every key form". It does not; that note was wrong and is corrected. + +Subgraph contents also gain `PORT_POSITION` node flexibility, which lets a node shift so an edge can leave straight rather than bending off the port. (Their placement strategy is covered in the `elk.preset` note.) + +**Existing diagrams with subgraphs will render differently** — groups get tighter and more even. diff --git a/.changeset/er-requirement-palette-css.md b/.changeset/er-requirement-palette-css.md new file mode 100644 index 00000000000..5d881d03f26 --- /dev/null +++ b/.changeset/er-requirement-palette-css.md @@ -0,0 +1,11 @@ +--- +'mermaid': patch +--- + +fix(er, requirement, timeline): stop the ER, requirement and timeline stylesheets emitting invalid CSS for the colour themes. + +All three generate one rule per palette slot, looping to `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Indexing raw means a palette with fewer entries than that limit emits `stroke: undefined` for the overflow slots; all three now wrap at the palette length, and bail before the loop for an empty palette — wrapping alone is not enough there, because `i % 0` is `NaN` and `[][NaN]` is `undefined`. + +`requirement` also emitted `fill: ;` — a property with no value, which is invalid — whenever there was no background palette. That is the live case for `redux-dark-color`, which ships a border palette and no background palette so that it colours outlines only. The declaration is now omitted instead. + +None of these raises an error: the browser discards the invalid declaration, so the only symptom is a shape rendering unstyled. diff --git a/.changeset/export-line-jumps.md b/.changeset/export-line-jumps.md new file mode 100644 index 00000000000..f116f721ef2 --- /dev/null +++ b/.changeset/export-line-jumps.md @@ -0,0 +1,9 @@ +--- +'mermaid': minor +--- + +feat: export `applyLineJumpsToSvg` so layout packages outside this one can draw line hops. + +Line jumps are applied after paint, once every edge has been emitted and the crossings are known. A layout that ships inside this package can reach into `rendering-util` to do that; one that ships separately, like `@mermaid-js/layout-elk`, cannot. Exporting it alongside the other common-renderer pieces lets an external layout register an `afterPaint` hook that draws hops the same way the built-in ones do. + +`EdgeGeom` and `LineJumpConfig` are exported with it, since they are the argument types. diff --git a/.changeset/fix-docs-build-destructive-order.md b/.changeset/fix-docs-build-destructive-order.md new file mode 100644 index 00000000000..a1d817aac96 --- /dev/null +++ b/.changeset/fix-docs-build-destructive-order.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix(docs): stop `docs:build` deleting the committed `docs/` directory when a later step fails. diff --git a/.changeset/intersect-line-half-pixel.md b/.changeset/intersect-line-half-pixel.md new file mode 100644 index 00000000000..9da61c0d6f5 --- /dev/null +++ b/.changeset/intersect-line-half-pixel.md @@ -0,0 +1,13 @@ +--- +'mermaid': patch +--- + +fix: edges attach to non-rectangular shapes on the outline instead of half a pixel off it. + +**This is not ELK-specific.** `intersectPolygon` is how every non-rectangular shape finds its edge attachment — diamond, stadium, hexagon, trapezoid, subroutine — in every layout, so a dagre-rendered flowchart with a decision diamond is affected exactly as much as an ELK one. + +`intersectLine` comes from Graphics Gems, where the coordinates were integers and the numerator was nudged half a denominator away from zero so the integer division rounded instead of truncating. JavaScript division does neither, so the nudge stopped being a correction and became the whole error: every result came back displaced by `0.5 * sign(num) * sign(denom)`. The magnitude is always exactly half a unit, but the sign is per axis, because the numerator is computed separately for x and y while the denominator is shared — so the two axes could move the same way or opposite ways depending on the geometry, which is why it never looked like a constant offset anyone could spot by eye. + +That was enough to give an otherwise orthogonal edge a tiny diagonal opening segment, and to put an attachment just inside the node it was meant to touch. + +`question.ts` had been subtracting a flat 0.5 from both axes to compensate for diamonds. That only cancelled the bias when both signs came out positive, and doubled the error to a full unit when they did not — so the compensation goes along with the cause. **Rendered output moves by up to a pixel wherever a polygon shape terminates an edge.** diff --git a/.changeset/line-hop-corner-clearance.md b/.changeset/line-hop-corner-clearance.md new file mode 100644 index 00000000000..f43629b4ae0 --- /dev/null +++ b/.changeset/line-hop-corner-clearance.md @@ -0,0 +1,13 @@ +--- +'mermaid': patch +--- + +fix: don't draw a line hop that has no room next to a bend. + +A crossing close to a corner used to get a hop squeezed into whatever space was left — as little as 2.9px against a requested 6px, opening exactly on the corner's tangent point. At that size the arc no longer clears the line it is meant to hop, so the two strokes still touch and the corner's curve runs straight into the arc's. It reads as a rendering fault rather than as a crossing. + +Hops now keep a straight run clear of the bend, and one that would still have to shrink below 60% of the requested radius is dropped instead of drawn. An undrawn hop is an ordinary crossing, which is a much better failure than a broken-looking one. + +**This changes swimlane diagrams as well as ELK ones.** Swimlanes are the existing consumer of line hops and have them on today, so a swimlane with a crossing near a bend will render differently even for someone not using ELK at all. It shows up wherever a layout stacks edges in narrow lanes: ELK routes subgraph-internal edges 10px apart, and 10px does not hold a 7.07px corner cut plus a 6px hop. + +A crossing is also ignored now when it lands inside the stretch where either edge is rounding a bend. Crossings are found on polylines, but a rounded edge is not drawn as its polyline — it leaves the line up to 7.07px before each bend and rejoins it that far after. A crossing found inside that stretch is somewhere the stroke never goes, so the hop was arching over blank paper while the two lines carried on touching beside it. diff --git a/.changeset/redux-color-theme-superset.md b/.changeset/redux-color-theme-superset.md new file mode 100644 index 00000000000..b2650f436e8 --- /dev/null +++ b/.changeset/redux-color-theme-superset.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix(themes): `redux-color` and `redux-dark-color` now define every variable their base themes define, instead of silently falling back to untuned values for `primaryBorderColor`, `clusterBkg`, `clusterBorder`, `altBackground`, `compositeTitleBackground` and the state and requirement edge-label backgrounds. Pie, gantt and user-journey also read the theme palette rather than a single pale tint, so pie slices are distinguishable and gantt sections are visibly banded. diff --git a/.changeset/sequence-palette-indexing.md b/.changeset/sequence-palette-indexing.md new file mode 100644 index 00000000000..dbbd68757bd --- /dev/null +++ b/.changeset/sequence-palette-indexing.md @@ -0,0 +1,9 @@ +--- +'mermaid': patch +--- + +fix(sequence): index each actor colour palette by its own length. + +Every actor-drawing call site read `bkgColorArray[actorCount % borderColorArray.length]` — one palette indexed by the other's length. Under `redux-color` both palettes have twelve entries, so the wrong length happens to give the right answer and nothing is visibly broken today. It goes wrong as soon as the two differ in length: the overflow actors resolve to `undefined`, and d3 then strips the inline fill for some actors and not others. + +Both palettes now cycle within their own length, via a shared helper. An absent or empty palette still yields `undefined` rather than a substitute colour, which is what `redux-dark-color` relies on: it ships a border palette and an empty background palette so actors are outlined but not filled. diff --git a/.cspell/code-terms.txt b/.cspell/code-terms.txt index 2b4cbffeff2..e18eae01847 100644 --- a/.cspell/code-terms.txt +++ b/.cspell/code-terms.txt @@ -53,6 +53,7 @@ DOUBLECIRCLEEND DOUBLECIRCLESTART DQUOTE DSTART +DUMMYNODE EBNF edgesep Eiglsperger @@ -101,6 +102,8 @@ minlen Mstartx MULT Naur +NIKOLOV +NODECOUNT NODIR nonterminal NSTR diff --git a/.github/workflows/e2e-applitools.yml b/.github/workflows/e2e-applitools.yml index 761a6a747d5..1d4f2e04bfd 100644 --- a/.github/workflows/e2e-applitools.yml +++ b/.github/workflows/e2e-applitools.yml @@ -49,7 +49,7 @@ jobs: env: MERMAID_PORT: 9000 APPLITOOLS_API_KEY: ${{ secrets.APPLITOOLS_API_KEY }} - APPLITOOLS_BATCH_ID: ${{ github.sha }} + APPLITOOLS_BATCH_ID: ${{ github.sha }}-${{ github.run_id }} # e.g. mermaid-js/mermaid/my-branch APPLITOOLS_BRANCH: ${{ github.repository }}/${{ github.ref_name }} APPLITOOLS_PARENT_BRANCH: ${{ github.event.inputs.parent_branch }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6ecb64005f5..ed323397f82 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -304,7 +304,6 @@ jobs: id: playwright env: MERMAID_PORT: 9000 - PLAYWRIGHT_COMMIT: ${{ github.sha }} # Enables native V8 coverage collection (page.coverage) on coverage runs. E2E_COVERAGE: ${{ (github.event_name == 'pull_request' || github.ref == 'refs/heads/develop') && 'true' || '' }} # Job output via env — never interpolate spec globs into the shell via ${{ }}. diff --git a/.github/workflows/refresh-sheet-order.yml b/.github/workflows/refresh-sheet-order.yml index edb0e7edc03..37968de904c 100644 --- a/.github/workflows/refresh-sheet-order.yml +++ b/.github/workflows/refresh-sheet-order.yml @@ -56,7 +56,6 @@ jobs: env: MERMAID_PORT: 9000 RUN_VISUAL_TEST: 'true' - PLAYWRIGHT_COMMIT: ${{ github.sha }} - name: Regenerate sheet order run: pnpm run screenshots:order diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 98cba0ae016..1e2a647b22f 100644 --- a/docs/config/setup/defaultConfig/variables/configKeys.md +++ b/docs/config/setup/defaultConfig/variables/configKeys.md @@ -12,4 +12,4 @@ > `const` **configKeys**: `Set`<`string`> -Defined in: [packages/mermaid/src/defaultConfig.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L343) +Defined in: [packages/mermaid/src/defaultConfig.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L354) diff --git a/docs/config/setup/mermaid/README.md b/docs/config/setup/mermaid/README.md index d76aef995e4..4d3dc34678c 100644 --- a/docs/config/setup/mermaid/README.md +++ b/docs/config/setup/mermaid/README.md @@ -18,9 +18,11 @@ - [CommonLayoutRenderContext](interfaces/CommonLayoutRenderContext.md) - [CommonLayoutRendererDefinition](interfaces/CommonLayoutRendererDefinition.md) - [DetailedError](interfaces/DetailedError.md) +- [EdgeGeom](interfaces/EdgeGeom.md) - [ExternalDiagramDefinition](interfaces/ExternalDiagramDefinition.md) - [LayoutData](interfaces/LayoutData.md) - [LayoutLoaderDefinition](interfaces/LayoutLoaderDefinition.md) +- [LineJumpConfig](interfaces/LineJumpConfig.md) - [Mermaid](interfaces/Mermaid.md) - [MermaidConfig](interfaces/MermaidConfig.md) - [ParseOptions](interfaces/ParseOptions.md) @@ -46,6 +48,7 @@ ## Functions +- [applyLineJumpsToSvg](functions/applyLineJumpsToSvg.md) - [clearLayoutRenderState](functions/clearLayoutRenderState.md) - [createCommonLayoutRenderer](functions/createCommonLayoutRenderer.md) - [defaultMeasureLayout](functions/defaultMeasureLayout.md) diff --git a/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md b/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md new file mode 100644 index 00000000000..18e5320b618 --- /dev/null +++ b/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md @@ -0,0 +1,39 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/functions/applyLineJumpsToSvg.md). + +[**mermaid**](../../README.md) + +--- + +# Function: applyLineJumpsToSvg() + +> **applyLineJumpsToSvg**(`edgePathsGroup`, `edges`, `config`): `void` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:646](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L646) + +Patches the rendered SVG paths in `edgePathsGroup` for any edges that +cross. The true geometry is read from each path's `data-points` attribute +(written by edges.js at render time) so the rewrite's endpoints match +exactly what was originally rendered. Edges whose curve is a true +smoothing curve (`basis`, `monotoneX`, …) are skipped. + +## Parameters + +### edgePathsGroup + +`D3Selection`<`SVGGElement`> + +### edges + +[`EdgeGeom`](../interfaces/EdgeGeom.md)\[] + +### config + +[`LineJumpConfig`](../interfaces/LineJumpConfig.md) + +## Returns + +`void` diff --git a/docs/config/setup/mermaid/interfaces/EdgeGeom.md b/docs/config/setup/mermaid/interfaces/EdgeGeom.md new file mode 100644 index 00000000000..26d14f335bb --- /dev/null +++ b/docs/config/setup/mermaid/interfaces/EdgeGeom.md @@ -0,0 +1,65 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/interfaces/EdgeGeom.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/interfaces/EdgeGeom.md). + +[**mermaid**](../../README.md) + +--- + +# Interface: EdgeGeom + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:56](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L56) + +## Properties + +### arrowTypeEnd? + +> `optional` **arrowTypeEnd**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:72](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L72) + +Arrow type at the end (last point). + +--- + +### arrowTypeStart? + +> `optional` **arrowTypeStart**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L70) + +Arrow type at the start (first point) — used to apply marker offset so +the rewritten path's endpoint matches the original rendered geometry and +the arrow marker orients correctly. + +--- + +### curve? + +> `optional` **curve**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:66](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L66) + +Optional curve hint matching `edge.curve` from the rendering layer. +When set, line jumps are only applied for orthogonal-friendly curves +(`'linear'`, `'rounded'`, `'step'`, `'stepBefore'`, `'stepAfter'`, or +undefined). Other curves (basis, monotoneX, …) are skipped to avoid +corrupting smoothed geometry. + +--- + +### id + +> **id**: `string` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:57](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L57) + +--- + +### points + +> **points**: `Point`\[] + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:58](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L58) diff --git a/docs/config/setup/mermaid/interfaces/LineJumpConfig.md b/docs/config/setup/mermaid/interfaces/LineJumpConfig.md new file mode 100644 index 00000000000..798fe18338c --- /dev/null +++ b/docs/config/setup/mermaid/interfaces/LineJumpConfig.md @@ -0,0 +1,37 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/interfaces/LineJumpConfig.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/interfaces/LineJumpConfig.md). + +[**mermaid**](../../README.md) + +--- + +# Interface: LineJumpConfig + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:75](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L75) + +## Properties + +### enabled + +> **enabled**: `boolean` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:76](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L76) + +--- + +### jumpRadius + +> **jumpRadius**: `number` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:77](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L77) + +--- + +### jumpStyle + +> **jumpStyle**: `"arc"` | `"gap"` + +Defined in: [packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts:78](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts#L78) diff --git a/docs/config/setup/mermaid/interfaces/Mermaid.md b/docs/config/setup/mermaid/interfaces/Mermaid.md index 1671a976d69..a99094d7ca7 100644 --- a/docs/config/setup/mermaid/interfaces/Mermaid.md +++ b/docs/config/setup/mermaid/interfaces/Mermaid.md @@ -10,7 +10,7 @@ # Interface: Mermaid -Defined in: [packages/mermaid/src/mermaid.ts:446](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L446) +Defined in: [packages/mermaid/src/mermaid.ts:451](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L451) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:446](https://github.com/mermaid-js/ > **contentLoaded**: () => `void` -Defined in: [packages/mermaid/src/mermaid.ts:464](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L464) +Defined in: [packages/mermaid/src/mermaid.ts:469](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L469) \##contentLoaded Callback function that is called when page is loaded. This functions fetches configuration for mermaid rendering and calls init for rendering the mermaid diagrams on the @@ -34,7 +34,7 @@ page. > **detectType**: (`text`, `config?`) => `string` -Defined in: [packages/mermaid/src/mermaid.ts:466](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L466) +Defined in: [packages/mermaid/src/mermaid.ts:471](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L471) Detects the type of the graph text. @@ -90,7 +90,7 @@ A graph definition key > **getRegisteredDiagramsMetadata**: () => `Pick`<[`ExternalDiagramDefinition`](ExternalDiagramDefinition.md), `"id"`>\[] -Defined in: [packages/mermaid/src/mermaid.ts:468](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L468) +Defined in: [packages/mermaid/src/mermaid.ts:473](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L473) Gets the metadata for all registered diagrams. Currently only the id is returned. @@ -107,7 +107,7 @@ An array of objects with the id of the diagram. > **init**: (`config?`, `nodes?`, `callback?`) => `Promise`<`void`> -Defined in: [packages/mermaid/src/mermaid.ts:459](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L459) +Defined in: [packages/mermaid/src/mermaid.ts:464](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L464) ## init @@ -155,7 +155,7 @@ Use [initialize](#initialize) and [run](#run) instead. > **initialize**: (`config`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:463](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L463) +Defined in: [packages/mermaid/src/mermaid.ts:468](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L468) Used to set configurations for mermaid. This function should be called before the run function. @@ -178,7 +178,7 @@ Configuration object for mermaid. > **mermaidAPI**: `Readonly`<{ `defaultConfig`: [`MermaidConfig`](MermaidConfig.md); `getConfig`: () => [`MermaidConfig`](MermaidConfig.md); `getDiagramFromText`: (`text`, `metadata`) => `Promise`<`Diagram`>; `getSiteConfig`: () => [`MermaidConfig`](MermaidConfig.md); `globalReset`: () => `void`; `initialize`: (`userOptions`) => `void`; `parse`: {(`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)>; (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)>; }; `render`: (`id`, `text`, `svgContainingElement?`) => `Promise`<[`RenderResult`](RenderResult.md)>; `reset`: () => `void`; `setConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); `updateSiteConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); }> -Defined in: [packages/mermaid/src/mermaid.ts:453](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L453) +Defined in: [packages/mermaid/src/mermaid.ts:458](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L458) **`Internal`** @@ -192,7 +192,7 @@ Use [parse](#parse) and [render](#render) instead. Please [open a discussion](ht > **parse**: {(`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)>; (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)>; } -Defined in: [packages/mermaid/src/mermaid.ts:454](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L454) +Defined in: [packages/mermaid/src/mermaid.ts:459](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L459) #### Call Signature @@ -268,7 +268,7 @@ Error if the diagram is invalid and parseOptions.suppressErrors is false or not > `optional` **parseError**: [`ParseErrorFunction`](../type-aliases/ParseErrorFunction.md) -Defined in: [packages/mermaid/src/mermaid.ts:448](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L448) +Defined in: [packages/mermaid/src/mermaid.ts:453](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L453) --- @@ -276,7 +276,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:448](https://github.com/mermaid-js/ > **registerExternalDiagrams**: (`diagrams`, `opts`) => `Promise`<`void`> -Defined in: [packages/mermaid/src/mermaid.ts:462](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L462) +Defined in: [packages/mermaid/src/mermaid.ts:467](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L467) Used to register external diagram types. @@ -306,7 +306,7 @@ If opts.lazyLoad is false, the diagrams will be loaded immediately. > **registerIconPacks**: (`iconLoaders`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:467](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L467) +Defined in: [packages/mermaid/src/mermaid.ts:472](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L472) #### Parameters @@ -324,7 +324,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:467](https://github.com/mermaid-js/ > **registerLayoutLoaders**: (`loaders`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:461](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L461) +Defined in: [packages/mermaid/src/mermaid.ts:466](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L466) #### Parameters @@ -342,7 +342,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:461](https://github.com/mermaid-js/ > **render**: (`id`, `text`, `svgContainingElement?`) => `Promise`<[`RenderResult`](RenderResult.md)> -Defined in: [packages/mermaid/src/mermaid.ts:455](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L455) +Defined in: [packages/mermaid/src/mermaid.ts:460](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L460) #### Parameters @@ -374,7 +374,7 @@ Deprecated for external use. > **run**: (`options`) => `Promise`<`void`> -Defined in: [packages/mermaid/src/mermaid.ts:460](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L460) +Defined in: [packages/mermaid/src/mermaid.ts:465](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L465) ## run @@ -418,7 +418,7 @@ Optional runtime configs > **setParseErrorHandler**: (`parseErrorHandler`) => `void` -Defined in: [packages/mermaid/src/mermaid.ts:465](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L465) +Defined in: [packages/mermaid/src/mermaid.ts:470](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L470) ## setParseErrorHandler Alternative to directly setting parseError using: @@ -449,4 +449,4 @@ New parseError() callback. > **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/mermaid.ts:447](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L447) +Defined in: [packages/mermaid/src/mermaid.ts:452](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L452) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index 400abd3371b..fbb1c3703cc 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/config.type.ts:66](https://github.com/mermaid- > `optional` **agentflow**: `AgentflowDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:236](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L236) +Defined in: [packages/mermaid/src/config.type.ts:322](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L322) --- @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/config.type.ts:236](https://github.com/mermaid > `optional` **altFontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:174](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L174) +Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L260) --- @@ -34,7 +34,7 @@ Defined in: [packages/mermaid/src/config.type.ts:174](https://github.com/mermaid > `optional` **architecture**: `ArchitectureDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:248](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L248) +Defined in: [packages/mermaid/src/config.type.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L334) --- @@ -42,7 +42,7 @@ Defined in: [packages/mermaid/src/config.type.ts:248](https://github.com/mermaid > `optional` **arrowMarkerAbsolute**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:193](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L193) +Defined in: [packages/mermaid/src/config.type.ts:279](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L279) Controls whether or arrow markers in html code are absolute paths or anchors. This matters if you are using base tag settings. @@ -53,7 +53,7 @@ This matters if you are using base tag settings. > `optional` **block**: `BlockDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:256](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L256) +Defined in: [packages/mermaid/src/config.type.ts:342](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L342) --- @@ -61,7 +61,7 @@ Defined in: [packages/mermaid/src/config.type.ts:256](https://github.com/mermaid > `optional` **c4**: `C4DiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:253](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L253) +Defined in: [packages/mermaid/src/config.type.ts:339](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L339) --- @@ -69,7 +69,7 @@ Defined in: [packages/mermaid/src/config.type.ts:253](https://github.com/mermaid > `optional` **class**: `ClassDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:241](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L241) +Defined in: [packages/mermaid/src/config.type.ts:327](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L327) --- @@ -77,7 +77,7 @@ Defined in: [packages/mermaid/src/config.type.ts:241](https://github.com/mermaid > `optional` **cynefin**: `CynefinDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:263](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L263) +Defined in: [packages/mermaid/src/config.type.ts:349](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L349) --- @@ -85,7 +85,7 @@ Defined in: [packages/mermaid/src/config.type.ts:263](https://github.com/mermaid > `optional` **darkMode**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:158](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L158) +Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L244) --- @@ -93,7 +93,7 @@ Defined in: [packages/mermaid/src/config.type.ts:158](https://github.com/mermaid > `optional` **deterministicIds**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:226](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L226) +Defined in: [packages/mermaid/src/config.type.ts:312](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L312) This option controls if the generated ids of nodes in the SVG are generated randomly or based on a seed. @@ -109,7 +109,7 @@ should not change unless content is changed. > `optional` **deterministicIDSeed**: `string` -Defined in: [packages/mermaid/src/config.type.ts:233](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L233) +Defined in: [packages/mermaid/src/config.type.ts:319](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L319) This option is the optional seed for deterministic ids. If set to `undefined` but deterministicIds is `true`, a simple number iterator is used. @@ -121,7 +121,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:265](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L265) +Defined in: [packages/mermaid/src/config.type.ts:351](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L351) --- @@ -139,7 +139,7 @@ Preserves the order of nodes and edges in the model file if this does not lead t #### cycleBreakingStrategy? -> `optional` **cycleBreakingStrategy**: `"GREEDY"` | `"DEPTH_FIRST"` | `"INTERACTIVE"` | `"MODEL_ORDER"` | `"GREEDY_MODEL_ORDER"` +> `optional` **cycleBreakingStrategy**: `"INTERACTIVE"` | `"GREEDY"` | `"DEPTH_FIRST"` | `"MODEL_ORDER"` | `"GREEDY_MODEL_ORDER"` This strategy decides how to find cycles in the graph and deciding which edges need adjustment to break loops. @@ -159,6 +159,41 @@ When a flow loops back on itself (a back-edge to an earlier node), ELK's degree- Only applies when the cyclic flow has no node without incoming edges: if the loop is fed from outside (e.g. a start node pointing into it), that component already has a natural source and nothing is pinned. Detection is also scoped per container, so cycles that cross a subgraph boundary are not detected. +#### layeringLayerBound? + +> `optional` **layeringLayerBound**: `number` + +Elk specific option capping how many nodes COFFMAN_GRAHAM will put in +one layer. Ignored by every other layering strategy. Lower values give +a taller, narrower drawing. + +#### layeringStrategy? + +> `optional` **layeringStrategy**: `"NETWORK_SIMPLEX"` | `"LONGEST_PATH"` | `"LONGEST_PATH_SOURCE"` | `"COFFMAN_GRAHAM"` | `"MIN_WIDTH"` | `"STRETCH_WIDTH"` | `"INTERACTIVE"` + +Elk specific option deciding which layer each node is assigned to — the +column in a left-to-right diagram, the row in a top-down one. This is +the coarsest of the three placement decisions, so changing it moves +nodes further than anything else short of altering spacing. + +NETWORK_SIMPLEX aims for the fewest long edges. LONGEST_PATH pushes +every node as late as it can go. COFFMAN_GRAHAM bounds how many nodes +share a layer, giving a more even, block-like shape on wide graphs. +MIN_WIDTH and STRETCH_WIDTH trade edge length for a narrower or wider +drawing. INTERACTIVE honours positions already on the nodes. + +#### lineHops? + +> `optional` **lineHops**: `boolean` | `"arc"` | `"gap"` + +Renders edge crossings as small arcs ("hops") or visible gaps, so that +it is clear which line passes over which where two edges meet. + +The edge that gives way loses its corner rounding for the segment +carrying the hop, which is the trade for a readable crossing. Curved +edges are skipped rather than rewritten, to avoid corrupting their +geometry. Set to `false` to draw plain crossings. + #### mergeEdges? > `optional` **mergeEdges**: `boolean` @@ -178,13 +213,62 @@ NONE picks the alignment with the smallest height. Elk specific option affecting how nodes are placed. +#### preset? + +> `optional` **preset**: `"legacy"` | `"default"` | `"modelOrder"` | `"depthFirst"` + +Named combination of the three options that decide where nodes end up: +layering strategy, node placement strategy and cycle breaking strategy. +They belong to different phases of the layout, so a preset is simply a +named triple rather than a mode with behaviour of its own. + +`default` — network simplex layering and placement with depth-first +cycle breaking at the top level, and Brandes-Koepf placement inside +subgraphs. Depth-first breaking gives shorter back edges on graphs +that loop. + +`legacy` — what shipped before presets existed: Brandes-Koepf placement, +which straightens long edges at the cost of that alignment, with ELK's +own greedy cycle breaking. Reproduces the rendering of earlier +versions rather than the defaults their schema advertised. + +`modelOrder` — as `default`, but breaks cycles by greedy model order, +which disturbs declaration order least at the cost of longer back +edges. This is the combination `default` named previously. + +`depthFirst` — a name for what `default` already is, for diagrams that +would rather say depth-first than rely on the default. + +Setting `layeringStrategy`, `nodePlacementStrategy` or +`cycleBreakingStrategy` explicitly overrides the preset for that one +option; the rest of the preset still applies. + +#### straightenEdges? + +> `optional` **straightenEdges**: `boolean` + +Straightens an edge that leaves or enters a node with a tiny step. + +ELK spreads an edge's port evenly along a node's side but routes the +edge down a channel whose row rarely lines up with that port exactly, +leaving a staircase of a few pixels right at the border. With rounded +corners the two micro-bends land on top of each other and read as a +kink. Enabling this moves the channel onto the port's row and drops +the step, so the edge draws as one straight line and both ports stay +exactly where the layout put them. + +Only the step next to a node is touched, and only when the edge +continues the same way afterwards, so a real turn is never collapsed. +An edge is left alone entirely when moving its run would drag the far +port, or would introduce a crossing. + --- ### er? > `optional` **er**: `ErDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:243](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L243) +Defined in: [packages/mermaid/src/config.type.ts:329](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L329) --- @@ -192,7 +276,7 @@ Defined in: [packages/mermaid/src/config.type.ts:243](https://github.com/mermaid > `optional` **eventmodeling**: `EventModelingDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:257](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L257) +Defined in: [packages/mermaid/src/config.type.ts:343](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L343) --- @@ -200,7 +284,7 @@ Defined in: [packages/mermaid/src/config.type.ts:257](https://github.com/mermaid > `optional` **flowchart**: `FlowchartDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:234](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L234) +Defined in: [packages/mermaid/src/config.type.ts:320](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L320) --- @@ -208,7 +292,7 @@ Defined in: [packages/mermaid/src/config.type.ts:234](https://github.com/mermaid > `optional` **fontFamily**: `string` -Defined in: [packages/mermaid/src/config.type.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L173) +Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L259) Specifies the font to be used in the rendered diagrams. Can be any possible CSS `font-family`. @@ -220,7 +304,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:267](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L267) +Defined in: [packages/mermaid/src/config.type.ts:353](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L353) --- @@ -228,7 +312,7 @@ Defined in: [packages/mermaid/src/config.type.ts:267](https://github.com/mermaid > `optional` **forceLegacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215) +Defined in: [packages/mermaid/src/config.type.ts:301](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L301) This option forces Mermaid to rely on KaTeX's own stylesheet for rendering MathML. Due to differences between OS fonts and browser's MathML implementation, this option is recommended if consistent rendering is important. @@ -240,7 +324,7 @@ If set to true, ignores legacyMathML. > `optional` **gantt**: `GanttDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L238) +Defined in: [packages/mermaid/src/config.type.ts:324](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L324) --- @@ -248,7 +332,7 @@ Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid > `optional` **gitGraph**: `GitGraphDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:252](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L252) +Defined in: [packages/mermaid/src/config.type.ts:338](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L338) --- @@ -266,7 +350,7 @@ Defines the seed to be used when using handDrawn look. This is important for the > `optional` **htmlLabels**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:166](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L166) +Defined in: [packages/mermaid/src/config.type.ts:252](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L252) Flag for setting whether or not a html tag should be used for rendering labels on nodes and edges. **Note:** Diagram-specific `htmlLabels` settings (e.g., `flowchart.htmlLabels`) are deprecated. @@ -279,7 +363,7 @@ over any diagram-specific settings. > `optional` **ishikawa**: `IshikawaDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:250](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L250) +Defined in: [packages/mermaid/src/config.type.ts:336](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L336) --- @@ -287,7 +371,7 @@ Defined in: [packages/mermaid/src/config.type.ts:250](https://github.com/mermaid > `optional` **journey**: `JourneyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:239](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L239) +Defined in: [packages/mermaid/src/config.type.ts:325](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L325) --- @@ -295,7 +379,7 @@ Defined in: [packages/mermaid/src/config.type.ts:239](https://github.com/mermaid > `optional` **kanban**: `KanbanDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:251](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L251) +Defined in: [packages/mermaid/src/config.type.ts:337](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L337) --- @@ -313,7 +397,7 @@ Defines which layout algorithm to use for rendering the diagram. > `optional` **legacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:208](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L208) +Defined in: [packages/mermaid/src/config.type.ts:294](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L294) This option specifies if Mermaid can expect the dependent to include KaTeX stylesheets for browsers without their own MathML implementation. If this option is disabled and MathML is not supported, the math @@ -326,7 +410,7 @@ fall back to legacy rendering for KaTeX. > `optional` **logLevel**: `0` | `2` | `1` | `"trace"` | `"debug"` | `"info"` | `"warn"` | `"error"` | `"fatal"` | `3` | `4` | `5` -Defined in: [packages/mermaid/src/config.type.ts:179](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L179) +Defined in: [packages/mermaid/src/config.type.ts:265](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L265) This option decides the amount of logging to be used by mermaid. @@ -346,7 +430,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:268](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L268) +Defined in: [packages/mermaid/src/config.type.ts:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L354) --- @@ -374,7 +458,7 @@ The maximum allowed size of the users text diagram > `optional` **mindmap**: `MindmapDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:249](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L249) +Defined in: [packages/mermaid/src/config.type.ts:335](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L335) --- @@ -382,7 +466,7 @@ Defined in: [packages/mermaid/src/config.type.ts:249](https://github.com/mermaid > `optional` **packet**: `PacketDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:255](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L255) +Defined in: [packages/mermaid/src/config.type.ts:341](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L341) --- @@ -390,7 +474,7 @@ Defined in: [packages/mermaid/src/config.type.ts:255](https://github.com/mermaid > `optional` **pie**: `PieDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L244) +Defined in: [packages/mermaid/src/config.type.ts:330](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L330) --- @@ -398,7 +482,7 @@ Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid > `optional` **quadrantChart**: `QuadrantChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) +Defined in: [packages/mermaid/src/config.type.ts:331](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L331) --- @@ -406,7 +490,7 @@ Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid > `optional` **radar**: `RadarDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L259) +Defined in: [packages/mermaid/src/config.type.ts:345](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L345) --- @@ -414,7 +498,7 @@ Defined in: [packages/mermaid/src/config.type.ts:259](https://github.com/mermaid > `optional` **railroad**: `RailroadDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:264](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L264) +Defined in: [packages/mermaid/src/config.type.ts:350](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L350) --- @@ -422,7 +506,7 @@ Defined in: [packages/mermaid/src/config.type.ts:264](https://github.com/mermaid > `optional` **requirement**: `RequirementDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:247](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L247) +Defined in: [packages/mermaid/src/config.type.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L333) --- @@ -430,7 +514,7 @@ Defined in: [packages/mermaid/src/config.type.ts:247](https://github.com/mermaid > `optional` **sankey**: `SankeyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L254) +Defined in: [packages/mermaid/src/config.type.ts:340](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L340) --- @@ -438,7 +522,7 @@ Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid > `optional` **secure**: `string`\[] -Defined in: [packages/mermaid/src/config.type.ts:200](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L200) +Defined in: [packages/mermaid/src/config.type.ts:286](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L286) This option controls which `currentConfig` keys are considered secure and can only be changed via call to `mermaid.initialize`. @@ -450,7 +534,7 @@ This prevents malicious graph directives from overriding a site's default securi > `optional` **securityLevel**: `"strict"` | `"loose"` | `"antiscript"` | `"sandbox"` -Defined in: [packages/mermaid/src/config.type.ts:183](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L183) +Defined in: [packages/mermaid/src/config.type.ts:269](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L269) Level of trust for parsed diagram @@ -460,7 +544,7 @@ Level of trust for parsed diagram > `optional` **sequence**: `SequenceDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L237) +Defined in: [packages/mermaid/src/config.type.ts:323](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L323) --- @@ -468,7 +552,7 @@ Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid > `optional` **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:187](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L187) +Defined in: [packages/mermaid/src/config.type.ts:273](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L273) Dictates whether mermaid starts on Page load @@ -478,7 +562,7 @@ Dictates whether mermaid starts on Page load > `optional` **state**: `StateDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:242](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L242) +Defined in: [packages/mermaid/src/config.type.ts:328](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L328) --- @@ -486,7 +570,7 @@ Defined in: [packages/mermaid/src/config.type.ts:242](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:274](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L274) +Defined in: [packages/mermaid/src/config.type.ts:360](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L360) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -497,7 +581,7 @@ This is useful when you want to control how to handle syntax errors in your appl > `optional` **swimlane**: `SwimlaneDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:235](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L235) +Defined in: [packages/mermaid/src/config.type.ts:321](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L321) --- @@ -532,7 +616,7 @@ Defined in: [packages/mermaid/src/config.type.ts:85](https://github.com/mermaid- > `optional` **timeline**: `TimelineDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:240](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L240) +Defined in: [packages/mermaid/src/config.type.ts:326](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L326) --- @@ -540,7 +624,7 @@ Defined in: [packages/mermaid/src/config.type.ts:240](https://github.com/mermaid > `optional` **treeView**: `TreeViewDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:258](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L258) +Defined in: [packages/mermaid/src/config.type.ts:344](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L344) --- @@ -548,7 +632,7 @@ Defined in: [packages/mermaid/src/config.type.ts:258](https://github.com/mermaid > `optional` **usecase**: `UsecaseDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L260) +Defined in: [packages/mermaid/src/config.type.ts:346](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L346) --- @@ -556,7 +640,7 @@ Defined in: [packages/mermaid/src/config.type.ts:260](https://github.com/mermaid > `optional` **venn**: `VennDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:261](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L261) +Defined in: [packages/mermaid/src/config.type.ts:347](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L347) --- @@ -564,7 +648,7 @@ Defined in: [packages/mermaid/src/config.type.ts:261](https://github.com/mermaid > `optional` **wardley-beta**: `WardleyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:262](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L262) +Defined in: [packages/mermaid/src/config.type.ts:348](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L348) --- @@ -572,7 +656,7 @@ Defined in: [packages/mermaid/src/config.type.ts:262](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:266](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L266) +Defined in: [packages/mermaid/src/config.type.ts:352](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L352) --- @@ -580,4 +664,4 @@ Defined in: [packages/mermaid/src/config.type.ts:266](https://github.com/mermaid > `optional` **xyChart**: `XYChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) +Defined in: [packages/mermaid/src/config.type.ts:332](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L332) diff --git a/docs/config/setup/mermaid/interfaces/RunOptions.md b/docs/config/setup/mermaid/interfaces/RunOptions.md index 03fcbfee8cd..5a0eae0610b 100644 --- a/docs/config/setup/mermaid/interfaces/RunOptions.md +++ b/docs/config/setup/mermaid/interfaces/RunOptions.md @@ -10,7 +10,7 @@ # Interface: RunOptions -Defined in: [packages/mermaid/src/mermaid.ts:58](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L58) +Defined in: [packages/mermaid/src/mermaid.ts:63](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L63) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:58](https://github.com/mermaid-js/m > `optional` **nodes**: `ArrayLike`<`HTMLElement`> -Defined in: [packages/mermaid/src/mermaid.ts:66](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L66) +Defined in: [packages/mermaid/src/mermaid.ts:71](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L71) The nodes to render. If this is set, `querySelector` will be ignored. @@ -28,7 +28,7 @@ The nodes to render. If this is set, `querySelector` will be ignored. > `optional` **postRenderCallback**: (`id`) => `unknown` -Defined in: [packages/mermaid/src/mermaid.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L70) +Defined in: [packages/mermaid/src/mermaid.ts:75](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L75) A callback to call after each diagram is rendered. @@ -48,7 +48,7 @@ A callback to call after each diagram is rendered. > `optional` **querySelector**: `string` -Defined in: [packages/mermaid/src/mermaid.ts:62](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L62) +Defined in: [packages/mermaid/src/mermaid.ts:67](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L67) The query selector to use when finding elements to render. Default: `".mermaid"`. @@ -58,6 +58,6 @@ The query selector to use when finding elements to render. Default: `".mermaid"` > `optional` **suppressErrors**: `boolean` -Defined in: [packages/mermaid/src/mermaid.ts:74](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L74) +Defined in: [packages/mermaid/src/mermaid.ts:79](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L79) If `true`, errors will be logged to the console, but not thrown. Default: `false` diff --git a/docs/config/setup/mermaid/variables/default.md b/docs/config/setup/mermaid/variables/default.md index 4a741a64b0c..03da9f13855 100644 --- a/docs/config/setup/mermaid/variables/default.md +++ b/docs/config/setup/mermaid/variables/default.md @@ -12,4 +12,4 @@ > `const` **default**: [`Mermaid`](../interfaces/Mermaid.md) -Defined in: [packages/mermaid/src/mermaid.ts:471](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L471) +Defined in: [packages/mermaid/src/mermaid.ts:476](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L476) diff --git a/e2e/helpers/applitools.spec.ts b/e2e/helpers/applitools.spec.ts new file mode 100644 index 00000000000..af303d7ebe3 --- /dev/null +++ b/e2e/helpers/applitools.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { applitoolsBatch, applitoolsTestName } from './applitools.ts'; + +describe('applitoolsBatch', () => { + it('groups mmd fixtures by their diagram folder, not the runner spec', () => { + expect( + applitoolsBatch('run1', 'rendering/mmd-snapshots.spec.ts', 'diagrams/flowchart/foo') + ).toEqual({ id: 'mermaid-batch-run1-diagrams/flowchart', name: 'diagrams/flowchart' }); + }); + + it('keeps nested fixture folders as their own batch', () => { + expect( + applitoolsBatch( + 'run1', + 'rendering/mmd-snapshots.spec.ts', + 'diagrams/c4/characterization/boundaries/x' + ).name + ).toBe('diagrams/c4/characterization/boundaries'); + }); + + it('puts every fixture in a folder into the same batch', () => { + const a = applitoolsBatch('run1', 'rendering/mmd-snapshots.spec.ts', 'diagrams/packet/a'); + const b = applitoolsBatch('run1', 'rendering/mmd-snapshots.spec.ts', 'diagrams/packet/b'); + expect(a).toEqual(b); + }); + + it('groups spec-based tests by the spec path', () => { + expect(applitoolsBatch('run1', 'rendering/flowchart/flowchart.spec.js')).toEqual({ + id: 'mermaid-batch-run1-rendering/flowchart/flowchart.spec.js', + name: 'rendering/flowchart/flowchart.spec.js', + }); + }); + + it('falls back to the spec when the screenshot path has no folder', () => { + expect(applitoolsBatch('run1', 'other/xss.spec.js', 'flat').name).toBe('other/xss.spec.js'); + }); + + it('separates runs by run id', () => { + const a = applitoolsBatch('run1', 'rendering/theme.spec.js'); + const b = applitoolsBatch('run2', 'rendering/theme.spec.js'); + expect(a.name).toBe(b.name); + expect(a.id).not.toBe(b.id); + }); +}); + +describe('applitoolsTestName', () => { + it('names mmd fixtures by their base name (the batch already names the folder)', () => { + expect( + applitoolsTestName( + 'rendering/mmd-snapshots.spec.ts-mmd-snapshots-flowchart-foo', + 'rendering/mmd-snapshots.spec.ts', + 'diagrams/flowchart/foo' + ) + ).toBe('foo'); + }); + + it('drops the spec-file prefix Playwright prepends to the title path', () => { + expect( + applitoolsTestName( + 'rendering/flowchart/flowchart.spec.js-Flowchart-1:-should-render', + 'rendering/flowchart/flowchart.spec.js' + ) + ).toBe('Flowchart-1:-should-render'); + }); + + it('keeps explicit names untouched', () => { + expect(applitoolsTestName('Basic-States', 'rendering/state/stateDiagram-neo.spec.js')).toBe( + 'Basic-States' + ); + }); + + it('falls back to the spec rule when the screenshot path has no folder', () => { + expect(applitoolsTestName('other/xss.spec.js-XSS-1', 'other/xss.spec.js', 'flat')).toBe( + 'XSS-1' + ); + }); +}); diff --git a/e2e/helpers/applitools.ts b/e2e/helpers/applitools.ts new file mode 100644 index 00000000000..fcd02d260ce --- /dev/null +++ b/e2e/helpers/applitools.ts @@ -0,0 +1,29 @@ +export interface ApplitoolsBatch { + id: string; + name: string; +} + +/** One batch per diagram folder (mmd fixtures) or spec file, like the Argos sheets. */ +export const applitoolsBatch = ( + runId: string, + specRelPath: string, + screenshotPath?: string +): ApplitoolsBatch => { + const segments = screenshotPath?.split('/') ?? []; + const name = segments.length > 1 ? segments.slice(0, -1).join('/') : specRelPath; + return { id: `mermaid-batch-${runId}-${name}`, name }; +}; + +/** Test name within the batch: fixture base name, or the title path without the spec-file prefix. */ +export const applitoolsTestName = ( + name: string, + specRelPath: string, + screenshotPath?: string +): string => { + const segments = screenshotPath?.split('/') ?? []; + if (segments.length > 1) { + return segments[segments.length - 1]; + } + const specPrefix = `${specRelPath.replace(/\s+/g, '-')}-`; + return name.startsWith(specPrefix) ? name.slice(specPrefix.length) : name; +}; diff --git a/e2e/helpers/util.ts b/e2e/helpers/util.ts index dffe003bb6f..3a0f2b469e1 100644 --- a/e2e/helpers/util.ts +++ b/e2e/helpers/util.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { basename, dirname, join, relative, sep } from 'node:path'; +import { dirname, join, relative, sep } from 'node:path'; import { expect, type Page, type TestInfo } from '@playwright/test'; import { Buffer } from 'buffer'; import type { MermaidConfig } from '../../packages/mermaid/src/config.type.js'; +import { applitoolsBatch, applitoolsTestName } from './applitools.ts'; import { buildCaptureMetadata, writeArgosMetadataSidecar } from './argos-metadata.ts'; import { collectCoverage, startCoverage } from './coverage.js'; @@ -34,12 +35,6 @@ export const utf8ToB64 = (str: string): string => { return Buffer.from(decodeURIComponent(encodeURIComponent(str))).toString('base64'); }; -const batchId: string = - 'mermaid-batch-' + - (process.env.USE_APPLI - ? Date.now().toString() - : (process.env.PLAYWRIGHT_COMMIT ?? Date.now().toString())); - /** Keep screenshot names within filesystem limits (ENAMETOOLONG on long test titles). */ const shortenScreenshotName = (name: string, maxLen = 180): string => { const sanitized = name.replace(/\s+/g, '-'); @@ -226,21 +221,26 @@ export const verifyScreenshot = async ( const svg = diagramSvg(page).first(); const hasSvg = (await svg.count()) > 0; const target = hasSvg ? svg : page; + const specRelPath = relative(testInfo.project.testDir, testInfo.file).split(sep).join('/'); if (useAppli) { - // Mirrors the Cypress eyes integration: one Applitools batch per spec file, - // a check per screenshot scoped to the diagram SVG (full window when there - // is none). API key, branch, and parent branch are read from the APPLITOOLS_* - // env vars by the SDK. Imported lazily so the SDK is only loaded for - // Applitools runs, not for Argos/local snapshot runs. + // One Applitools batch per diagram folder or spec file, a check per screenshot + // scoped to the diagram SVG (full window when there is none). API key, branch, + // and parent branch are read from the APPLITOOLS_* env vars by the SDK. + // Imported lazily so the SDK is only loaded for Applitools runs. const { Eyes, ClassicRunner, Target } = await import('@applitools/eyes-playwright'); - const specName = basename(testInfo.file); + const runId = process.env.APPLITOOLS_BATCH_ID; + if (!runId) { + throw new Error( + 'APPLITOOLS_BATCH_ID is unset; playwright.config.ts should seed it for USE_APPLI runs' + ); + } const eyes = new Eyes(new ClassicRunner()); eyes.setConfiguration({ appName: 'Mermaid', - batch: { id: batchId + specName, name: specName }, + batch: applitoolsBatch(runId, specRelPath, screenshotPath), }); - await eyes.open(page, 'Mermaid', name); + await eyes.open(page, 'Mermaid', applitoolsTestName(name, specRelPath, screenshotPath)); await eyes.check( 'Click!', hasSvg ? Target.region('svg[aria-roledescription]') : Target.window().fully() @@ -266,7 +266,6 @@ export const verifyScreenshot = async ( // GitHub artifacts reject (" : < > | * ?) or path separators; flatten it to // a safe slug. The spec folder lives in specRelPath, which the batch job // groups by, so the filename only needs to be unique within the spec. - const specRelPath = relative(testInfo.project.testDir, testInfo.file).split(sep).join('/'); outPath = join(screenshotDir, specRelPath, `${sanitizeSegment(name)}.png`); } mkdirSync(dirname(outPath), { recursive: true }); diff --git a/e2e/rendering/redux-color-chart-themes.spec.ts b/e2e/rendering/redux-color-chart-themes.spec.ts new file mode 100644 index 00000000000..dc95b66000b --- /dev/null +++ b/e2e/rendering/redux-color-chart-themes.spec.ts @@ -0,0 +1,127 @@ +import { test } from '@playwright/test'; +import { imgSnapshotTest } from '../helpers/util.ts'; + +/** + * pie, gantt, user-journey and state are the diagrams whose palettes the redux colour + * themes actually change, and until now none of them were rendered under those themes + * anywhere in the suite. The existing redux/neo theme specs cover er, gitGraph, mindmap, + * requirement, sequence and timeline — so the diagrams with the largest visual delta had + * no safety net, which is how the original fork-and-drift went unnoticed. + * + * Unlike `sequenceDiagram-redux-themes.spec.ts` these run under the default `classic` + * look: none of the four is neo-specific, and the palette wiring under test is + * look-independent. + */ +const reduxThemes = ['redux', 'redux-color', 'redux-dark', 'redux-dark-color'] as const; + +/** Twelve slices, so the whole categorical scale is exercised and wrap-around is visible. */ +const pieDiagram = ` + pie title Slice palette + "Alpha" : 20 + "Bravo" : 16 + "Charlie" : 14 + "Delta" : 12 + "Echo" : 10 + "Foxtrot" : 8 + "Golf" : 7 + "Hotel" : 6 + "India" : 3 + "Juliett" : 2 + "Kilo" : 1 + "Lima" : 1 +`; + +/** + * Four sections, because gantt cycles four band classes: `.section0` takes + * `sectionBkgColor`, `.section1` and `.section3` take `altSectionBkgColor`, and + * `.section2` takes `sectionBkgColor2`. Three sections would cover `altSectionBkgColor` + * only once and hide that it is half of every gantt's banding. Also exercises the active, + * done and crit task states, which read separate variables. + */ +const ganttDiagram = ` + gantt + title Section banding + dateFormat YYYY-MM-DD + section Discovery + Interviews :done, a1, 2024-01-01, 12d + Synthesis :active, a2, after a1, 8d + section Design + Wireframes :b1, 2024-01-10, 10d + Review :crit, b2, after b1, 6d + section Build + Backend :c1, 2024-01-20, 14d + Frontend :c2, after c1, 12d + section Launch + Beta :d1, 2024-02-10, 8d +`; + +/** Eight sections, so all of fillType0..7 are exercised rather than just the first few. */ +const journeyDiagram = ` + journey + title Task and section fills + section Browse + Land on site: 5: Visitor + Search: 3: Visitor + section Cart + Add item: 4: Visitor + View cart: 3: Visitor + section Pay + Enter card: 2: Visitor + Confirm: 5: Visitor + section Fulfil + Pack: 4: Warehouse + Ship: 3: Warehouse + section Deliver + Handover: 5: Courier + section Support + Contact: 2: Visitor + section Return + Request: 1: Visitor + section Close + Archive: 4: Visitor +`; + +/** + * Composite states and transition labels, which is what exercises + * `compositeTitleBackground` and `altBackground` — two of the variables that were untuned + * in the colour themes. + * + * `stateEdgeLabelBackground` is deliberately not in that list even though this change adds + * it: nothing reads it. `state/styles.js` uses the unprefixed `edgeLabelBackground`, and a + * grep finds the prefixed name only in the theme files themselves. It is added for + * superset parity with the base themes, which already carry it, and has no rendered + * effect — so no fixture can cover it. + */ +const stateDiagram = ` + stateDiagram-v2 + [*] --> Idle + Idle --> Working: start + state Working { + [*] --> Fetching + Fetching --> Parsing: bytes ready + Parsing --> [*] + } + Working --> Idle: done + Working --> Failed: error + Failed --> Idle: retry + Failed --> [*] +`; + +const diagrams = { + pie: pieDiagram, + gantt: ganttDiagram, + 'user-journey': journeyDiagram, + state: stateDiagram, +} as const; + +test.describe('Chart diagrams - Redux colour themes', () => { + for (const theme of reduxThemes) { + test.describe(`Theme: ${theme}`, () => { + for (const [name, diagram] of Object.entries(diagrams)) { + test(`should render ${name} with the theme palette`, async ({ page }, testInfo) => { + await imgSnapshotTest(page, testInfo, diagram, { theme }); + }); + } + }); + } +}); diff --git a/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts b/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts index 101dc5f10ce..6a2a9f5f8e1 100644 --- a/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/geometry.spec.ts @@ -5,9 +5,13 @@ import { makeInsidePoint, tryNodeIntersect, replaceEndpoint, + outlineAttachPoint, type RectLike, type P, } from '../geometry.js'; +// Lives in render.ts rather than geometry.ts, but it is pure point maths and +// belongs with the other geometry cases. +import { straightenTerminalJogs } from '../render.js'; const approx = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) < eps; @@ -64,4 +68,221 @@ describe('geometry helpers', () => { replaceEndpoint(pts2, 'start', { x: 0, y: 0 }); expect(pts2.length).toBe(1); }); + + describe('outlineAttachPoint', () => { + // A diamond 105.48 wide and tall, centred like the `diamond-intersections` + // fixture: outline through the midpoints of its bounding box sides. + const half = 52.74; + const bounds: RectLike = { x: 76.74, y: 428.5, width: half * 2, height: half * 2 }; + const diamond = { + intersect: (p: P): P => { + // Crossing of the ray centre -> p with |dx|/half + |dy|/half = 1. + const dx = p.x - bounds.x; + const dy = p.y - bounds.y; + const t = half / (Math.abs(dx) + Math.abs(dy)); + return { x: bounds.x + dx * t, y: bounds.y + dy * t }; + }, + }; + + it('attaches on the outline at the port’s own offset along the side', () => { + // ELK's port sits on the bounding box at y = 454.87 and departs east. The + // attachment must keep that y, so the opening segment stays horizontal; + // the centre ray would instead land at y = 446.08 and open diagonally. + const port: P = { x: 129.48, y: 454.87 }; + const next: P = { x: 144.48, y: 454.87 }; + + const attach = outlineAttachPoint(diamond, bounds, port, next)!; + + expect(attach.y).toBe(port.y); + expect(approx(attach.x, 103.11, 0.01)).toBe(true); + // On the outline: |dx| + |dy| === half. + expect( + approx(Math.abs(attach.x - bounds.x) + Math.abs(attach.y - bounds.y), half, 0.01) + ).toBe(true); + }); + + it('keeps a vertical departure vertical', () => { + const port: P = { x: 60, y: 481.24 }; + const next: P = { x: 60, y: 520 }; + + const attach = outlineAttachPoint(diamond, bounds, port, next)!; + + expect(attach.x).toBe(port.x); + expect( + approx(Math.abs(attach.x - bounds.x) + Math.abs(attach.y - bounds.y), half, 0.01) + ).toBe(true); + }); + + it('returns the port unchanged for a shape whose outline is its box', () => { + const rect: RectLike = { x: 100, y: 100, width: 80, height: 40 }; + const rectNode = { + intersect: (p: P): P => { + const dx = p.x - rect.x; + const dy = p.y - rect.y; + const t = Math.min(40 / Math.abs(dx || 1e-9), 20 / Math.abs(dy || 1e-9)); + return { x: rect.x + dx * t, y: rect.y + dy * t }; + }, + }; + const port: P = { x: 140, y: 112 }; + + const attach = outlineAttachPoint(rectNode, rect, port, { x: 180, y: 112 })!; + + expect(approx(attach.x, port.x, 0.01)).toBe(true); + expect(approx(attach.y, port.y, 0.01)).toBe(true); + }); + + it('declines a shapeless node rather than guessing', () => { + expect( + outlineAttachPoint({}, bounds, { x: 129.48, y: 454.87 }, { x: 144.48, y: 454.87 }) + ).toBe(null); + }); + + it('declines a diagonal departure rather than forcing it onto an axis', () => { + // The bisection walks one axis holding the other fixed, so a diagonal has + // no axis to preserve. Forcing it onto the dominant one would attach at a + // point the edge does not pass through — reintroducing exactly the offset + // this function exists to remove — so the caller's centre-ray path is the + // honest fallback. + const port = { x: 129.48, y: 454.87 }; + + expect(outlineAttachPoint(diamond, bounds, port, { x: 144.48, y: 469.87 })).toBe(null); + // Dominant-x and dominant-y diagonals are both declined, not just the 45°. + expect(outlineAttachPoint(diamond, bounds, port, { x: 174.48, y: 459.87 })).toBe(null); + expect(outlineAttachPoint(diamond, bounds, port, { x: 134.48, y: 494.87 })).toBe(null); + }); + + it('still accepts a departure carrying floating-point dust', () => { + // ELK emits exact orthogonal stubs, but the numbers reaching here have been + // through arithmetic. A sub-nanometre minor component is not a diagonal. + const port = { x: 129.48, y: 454.87 }; + + expect(outlineAttachPoint(diamond, bounds, port, { x: 144.48, y: 454.87 + 1e-9 })).not.toBe( + null + ); + }); + }); + + describe('straightenTerminalJogs', () => { + // Every case keeps BOTH ports exactly where they are: the step is removed by + // moving the channel onto the port's row, never the other way round. Moving + // a port slides the attachment along the node border and leaves a node whose + // other edges are still evenly spread looking lopsided. + + it('moves the channel onto the port row, leaving the port alone', () => { + // Leaves the port at y=116.25, steps 3.25 down onto the channel, runs on, + // then turns. The run moves up to the port instead. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 218, y: 116.25 }, + { x: 218, y: 119.5 }, + { x: 400, y: 119.5 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual([ + { x: 193, y: 116.25 }, + { x: 400, y: 116.25 }, + { x: 400, y: 300 }, + ]); + }); + + it('moves a whole multi-segment run, not just its first leg', () => { + // The channel carries on past the first bend. Shifting only part of it + // would leave a diagonal where the moved and unmoved halves meet. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 218, y: 116.25 }, + { x: 218, y: 119.5 }, + { x: 300, y: 119.5 }, + { x: 400, y: 119.5 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual([ + { x: 193, y: 116.25 }, + { x: 300, y: 116.25 }, + { x: 400, y: 116.25 }, + { x: 400, y: 300 }, + ]); + }); + + it('handles a sub-pixel step', () => { + // ELK routinely leaves under a pixel between port row and channel row. It + // still paints as two rounded corners stacked on each other. + const pts: P[] = [ + { x: 193, y: 116.5 }, + { x: 218, y: 116.5 }, + { x: 218, y: 117.358 }, + { x: 400, y: 117.358 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual([ + { x: 193, y: 116.5 }, + { x: 400, y: 116.5 }, + { x: 400, y: 300 }, + ]); + }); + + it('refuses when the run ends at the far port', () => { + // Moving this run would drag the other end's port — the very thing the + // rewrite avoids — so the edge is left exactly as routed. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 218, y: 116.25 }, + { x: 218, y: 119.5 }, + { x: 300, y: 119.5 }, + { x: 400, y: 119.5 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a step too large to be a port connector', () => { + const pts: P[] = [ + { x: 193, y: 100 }, + { x: 218, y: 100 }, + { x: 218, y: 140 }, + { x: 400, y: 140 }, + { x: 400, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a step that turns too far from the node', () => { + // Small step, but the corner is 120 out: a routing decision, not the + // port-to-channel connector. + const pts: P[] = [ + { x: 193, y: 116.25 }, + { x: 313, y: 116.25 }, + { x: 313, y: 119.5 }, + { x: 415, y: 119.5 }, + { x: 415, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a step that reverses direction', () => { + const pts: P[] = [ + { x: 193, y: 116 }, + { x: 218, y: 116 }, + { x: 218, y: 119 }, + { x: 100, y: 119 }, + { x: 100, y: 300 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + + it('leaves a route with nothing to collapse', () => { + const pts: P[] = [ + { x: 193, y: 120 }, + { x: 315, y: 120 }, + ]; + + expect(straightenTerminalJogs(pts)).toEqual(pts); + }); + }); }); diff --git a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts index 8b93dbe9785..fcb387bca37 100644 --- a/packages/mermaid-layout-elk/src/__tests__/render.spec.ts +++ b/packages/mermaid-layout-elk/src/__tests__/render.spec.ts @@ -5,9 +5,11 @@ import { clearContainerAlgorithmOptions, dir2ElkDirection, ensureEndMarkerSegmentLength, + evenGroupFrames, findCyclicEntryNodes, prepareLayoutForElk, resolveContainerAlgorithm, + resolveElkPreset, runElkLayoutCore, } from '../render.js'; @@ -113,7 +115,7 @@ describe('buildSubgraphLayoutOptions', () => { { nodePlacementStrategy: 'BRANDES_KOEPF' }, 'layered' ); - expect(opts['nodePlacement.strategy']).toBe('BRANDES_KOEPF'); + expect(opts['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); }); it('defaults nodePlacementAlignment to NONE', () => { @@ -129,10 +131,51 @@ describe('buildSubgraphLayoutOptions', () => { it('handles undefined elkConfig gracefully', () => { const opts = buildSubgraphLayoutOptions({}, undefined, 'layered'); expect(opts['elk.layered.mergeEdges']).toBeUndefined(); - expect(opts['nodePlacement.strategy']).toBeUndefined(); + // With no config at all the `default` preset supplies the placement + // strategy. Containers are BRANDES_KOEPF while the root is NETWORK_SIMPLEX: + // network simplex inside a frame produced routes that left a subgraph on + // its bounding-box corner, so containers keep the strategy that does not. + expect(opts['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); expect(opts['elk.layered.nodePlacement.bk.fixedAlignment']).toBe('NONE'); }); + it('lets an explicit strategy beat the preset', () => { + // A preset is a starting point, not a lock: naming one option explicitly + // overrides that option and leaves the rest of the preset in place. + const opts = buildSubgraphLayoutOptions( + {}, + { preset: 'legacy', nodePlacementStrategy: 'SIMPLE' }, + 'layered' + ); + expect(opts['elk.layered.nodePlacement.strategy']).toBe('SIMPLE'); + }); + + it('takes the container placement strategy from the named preset', () => { + const placement = (preset: string) => + buildSubgraphLayoutOptions({}, { preset }, 'layered')['elk.layered.nodePlacement.strategy']; + + // `legacy` exists to reproduce what earlier versions rendered, so it has to + // reach containers too — leaving them on the new strategy would make it a + // half-restore that still lays subgraph contents out differently. + expect(placement('legacy')).toBe('BRANDES_KOEPF'); + // `default` and `depthFirst` place the ROOT with NETWORK_SIMPLEX but keep + // containers on BRANDES_KOEPF — the two sides are tuned separately on + // purpose, so a change to one must not be assumed to carry to the other. + expect(placement('depthFirst')).toBe('BRANDES_KOEPF'); + expect(placement('default')).toBe('BRANDES_KOEPF'); + }); + + it('names the placement option once, fully qualified', () => { + // ELK reads `nodePlacement.strategy` and `elk.layered.nodePlacement.strategy` + // as the same option. Setting both left the container holding two values + // for it with no say in which won, which silently ignored an explicit + // `nodePlacementStrategy`. + const opts = buildSubgraphLayoutOptions({}, { nodePlacementStrategy: 'SIMPLE' }, 'layered'); + + expect(opts).not.toHaveProperty('nodePlacement.strategy'); + expect(opts['elk.layered.nodePlacement.strategy']).toBe('SIMPLE'); + }); + it('applies a per-group algorithm from metadata with SEPARATE_CHILDREN', () => { const opts = buildSubgraphLayoutOptions( { labelData: { width: 30, height: 14 }, metadata: { algorithm: 'elk.box' } }, @@ -735,8 +778,13 @@ describe('clearContainerAlgorithmOptions', () => { clearContainerAlgorithmOptions(options); - expect(options['spacing.baseValue']).toBe(30); - expect(options).not.toHaveProperty('spacing.nodeNode'); + // Back to DEFAULT_SUBGRAPH_SPACING_BASE_VALUE. It no longer buys the + // approach run or the node gap — those are set on their own now — so it is + // free to be small, and the container's padding follows it. + expect(options['spacing.baseValue']).toBe(24); + // Restored too, rather than left deleted: rectpacking overrode it, and + // dropping it would hand the container ELK's node spacing instead of ours. + expect(options['spacing.nodeNode']).toBe(50); expect(options).not.toHaveProperty('elk.rectpacking.trybox'); }); @@ -749,7 +797,328 @@ describe('clearContainerAlgorithmOptions', () => { clearContainerAlgorithmOptions(options); expect(options['elk.layered.mergeEdges']).toBe(true); - expect(options['nodePlacement.strategy']).toBe('BRANDES_KOEPF'); + expect(options['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); expect(options['nodeLabels.placement']).toBe('[H_CENTER V_TOP, INSIDE]'); }); + + describe('evenGroupFrames', () => { + /** + * Build the two structures the pass reads: the ELK tree (for `isGroup` and + * `children`) and `nodeDb`, whose entries carry the absolute box that + * `applyElkNodePositions` has already written. + */ + function scene(groupBox: { x: number; y: number; w: number; h: number }, kids: number[][]) { + const nodeDb: Record = { + g: { + id: 'g', + isGroup: true, + offset: { posX: groupBox.x, posY: groupBox.y }, + width: groupBox.w, + height: groupBox.h, + }, + }; + const children = kids.map(([x, y, w, h], i) => { + nodeDb[`n${i}`] = { id: `n${i}`, offset: { posX: x, posY: y }, width: w, height: h }; + return { id: `n${i}` }; + }); + const elk = [{ id: 'g', isGroup: true, children, labelData: { width: 0 }, labels: [] }]; + return { elk, nodeDb, layoutState: { nodeDb } as any }; + } + + it('pulls a frame in to even padding when a routing lane inflated one side', () => { + // The reported shape: nodes 100 wide sitting 24 from the left of the frame, + // with the frame running 76 past their right because ELK held a lane there + // for an edge routed back around the outside. + const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 200, h: 148 }, [ + [24, 48, 100, 76], + ]); + + evenGroupFrames(elk, layoutState, new Map()); + + const g = nodeDb.g; + expect(g.offset.posX).toBe(0); + expect(g.width).toBe(148); // 24 + 100 + 24 + // Top is deliberately untouched: it carries the subgraph's title strip. + expect(g.offset.posY).toBe(0); + expect(g.height).toBe(148); // 48 title strip + 76 + 24 + }); + + it('leaves a frame alone when its padding is already even', () => { + const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 148, h: 148 }, [ + [24, 48, 100, 76], + ]); + + evenGroupFrames(elk, layoutState, new Map()); + + expect(nodeDb.g.width).toBe(148); + expect(nodeDb.g.height).toBe(148); + }); + + it('never squeezes a frame narrower than its own title', () => { + // A one-node group under a long title. Pulling in to the node would cut + // the title off, so the floor wins it back. + const { elk, nodeDb, layoutState } = scene({ x: 0, y: 0, w: 300, h: 148 }, [ + [24, 48, 40, 76], + ]); + elk[0].labelData = { width: 200 }; + + evenGroupFrames(elk, layoutState, new Map()); + + expect(nodeDb.g.width).toBe(200); + // Centring on the node's midpoint at x=44 would want to start at -56, but + // the frame is clamped to what ELK gave. Pulling a frame IN is the only + // thing this pass may do — a title too wide for its own frame is ELK's to + // size, and widening it here would paper over that. + expect(nodeDb.g.offset.posX).toBe(0); + }); + + it('measures a parent against children it has already pulled in', () => { + // Nested groups: the inner frame is inflated by 76 on the right and the + // outer one wraps it. Going deepest-first means the outer frame measures + // the tightened inner box, not the original. + const nodeDb: Record = { + outer: { + id: 'outer', + isGroup: true, + offset: { posX: 0, posY: 0 }, + width: 300, + height: 220, + }, + inner: { + id: 'inner', + isGroup: true, + offset: { posX: 24, posY: 48 }, + width: 200, + height: 148, + }, + leaf: { id: 'leaf', offset: { posX: 48, posY: 96 }, width: 100, height: 76 }, + }; + const elk = [ + { + id: 'outer', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [ + { + id: 'inner', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [{ id: 'leaf' }], + }, + ], + }, + ]; + + evenGroupFrames(elk, { nodeDb } as any, new Map()); + + expect(nodeDb.inner.width).toBe(148); // 24 + 100 + 24 + expect(nodeDb.outer.width).toBe(196); // 24 + 148 + 24 + }); + + it("keeps a frame around a lane belonging to the group's own interior", () => { + // The nested case. An edge from inside C to a sibling of C is routed around + // C: that lane is OUTSIDE C, so C is pulled in past it, but it is INSIDE P + // and P must stay drawn around it. Measuring P from child boxes alone left + // the edge running outside a group it never leaves. + const nodeDb: Record = { + P: { id: 'P', isGroup: true, offset: { posX: 0, posY: 0 }, width: 400, height: 220 }, + C: { id: 'C', isGroup: true, offset: { posX: 24, posY: 48 }, width: 200, height: 148 }, + leaf: { id: 'leaf', offset: { posX: 48, posY: 96 }, width: 100, height: 76 }, + sib: { id: 'sib', offset: { posX: 260, posY: 96 }, width: 60, height: 76 }, + }; + const elk = [ + { + id: 'P', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [ + { + id: 'C', + isGroup: true, + labelData: { width: 0 }, + labels: [], + children: [{ id: 'leaf' }], + }, + { id: 'sib' }, + ], + }, + ]; + // Routed out of `leaf`, around C at x=350, and back to `sib`. Sections sit + // in P's coordinate space, so `calcOffset` resolves them against P. + const graph = { + edges: [ + { + id: 'e', + sources: ['leaf'], + targets: ['sib'], + sections: [ + { + startPoint: { x: 148, y: 134 }, + bendPoints: [ + { x: 350, y: 134 }, + { x: 350, y: 60 }, + ], + endPoint: { x: 260, y: 134 }, + }, + ], + }, + ], + }; + const layoutState = { + nodeDb, + parentLookupDb: { parentById: { leaf: 'C', C: 'P', sib: 'P' } }, + }; + + evenGroupFrames(elk, layoutState as any, new Map(), graph as any); + + // C ignores the lane — it leaves C — and pulls in to its one child. + expect(nodeDb.C.width).toBe(148); + // P keeps it: the lane reaches x=350, so the frame runs to 350 + 24. + expect(nodeDb.P.offset.posX + nodeDb.P.width).toBe(374); + }); + + it("leaves ELK's own origin readable after moving a frame", () => { + // Edge sections resolve against the container's ORIGINAL origin, so moving + // a frame must not overwrite it or every edge inside would shift with it. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 200, height: 148 }, + n: { id: 'n', offset: { posX: 40, posY: 48 }, width: 100, height: 76 }, + }; + + evenGroupFrames( + [{ id: 'g', isGroup: true, labelData: { width: 0 }, labels: [], children: [{ id: 'n' }] }], + { nodeDb } as any, + new Map() + ); + + expect(nodeDb.g.offset.posX).toBe(16); // frame moved in to 40 - 24 + expect(nodeDb.g.elkOrigin).toEqual({ posX: 0, posY: 0 }); + }); + + it('never grows a frame beyond what ELK sized it to', () => { + // ELK sized the container around everything it put inside, so a measurement + // here that wants MORE room means this pass got something wrong. Clamp + // rather than trust it. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 120, height: 148 }, + n: { id: 'n', offset: { posX: 10, posY: 48 }, width: 100, height: 76 }, + }; + + evenGroupFrames( + [{ id: 'g', isGroup: true, labelData: { width: 0 }, labels: [], children: [{ id: 'n' }] }], + { nodeDb } as any, + new Map() + ); + + // 10 - 24 would put the left edge at -14 and 110 + 24 the right at 134; + // both are clamped back to the frame ELK gave. + expect(nodeDb.g.offset.posX).toBe(0); + expect(nodeDb.g.width).toBe(120); + }); + + it('paints the clamped frame, not the wider title, into the layout node', () => { + // Every other test here passes an empty `nodeById`, so the branch that + // writes the node the renderer actually paints from went uncovered — and + // that is where the clamp was being undone. + // + // A 100-wide frame under a 200-wide title. The clamp refuses to widen the + // frame, so the layout node must not report 200 either: `clusters.js` + // sizes the painted rect from `node.width`, so a 200 here paints a frame + // 100 units wider than ELK reserved. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 100, height: 148 }, + n: { id: 'n', offset: { posX: 10, posY: 48 }, width: 40, height: 76 }, + }; + const layoutNode = { id: 'g', x: 0, y: 0, width: 0, height: 0 } as any; + + evenGroupFrames( + [ + { + id: 'g', + isGroup: true, + labelData: { width: 200 }, + labels: [], + children: [{ id: 'n' }], + }, + ], + { nodeDb } as any, + new Map([['g', layoutNode]]) + ); + + expect(nodeDb.g.width).toBe(100); + expect(layoutNode.width).toBe(100); + expect(layoutNode.width).toBe(nodeDb.g.width); + }); + + it('still reports the honoured title floor when ELK left room for it', () => { + // The other side of the same branch: here the 200-wide title fits inside + // the 300 ELK gave, so the floor is honoured and the layout node carries + // it. Without this the fix above could pass by always shrinking. + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 300, height: 148 }, + n: { id: 'n', offset: { posX: 24, posY: 48 }, width: 40, height: 76 }, + }; + const layoutNode = { id: 'g', x: 0, y: 0, width: 0, height: 0 } as any; + + evenGroupFrames( + [ + { + id: 'g', + isGroup: true, + labelData: { width: 200 }, + labels: [], + children: [{ id: 'n' }], + }, + ], + { nodeDb } as any, + new Map([['g', layoutNode]]) + ); + + expect(nodeDb.g.width).toBe(200); + expect(layoutNode.width).toBe(200); + }); + + it('skips a group with no children rather than collapsing it', () => { + const nodeDb: Record = { + g: { id: 'g', isGroup: true, offset: { posX: 0, posY: 0 }, width: 200, height: 100 }, + }; + + evenGroupFrames([{ id: 'g', isGroup: true, children: [] }], { nodeDb } as any, new Map()); + + expect(nodeDb.g.width).toBe(200); + expect(nodeDb.g.height).toBe(100); + }); + }); +}); + +describe('resolveElkPreset', () => { + it('breaks cycles depth first by default', () => { + // Depth-first took over as the default because it gives shorter back edges + // on graphs that loop; the greedy-model-order triple it displaced is still + // reachable, under its own name. + expect(resolveElkPreset(undefined).cycleBreaking).toBe('DEPTH_FIRST'); + expect(resolveElkPreset('default').cycleBreaking).toBe('DEPTH_FIRST'); + expect(resolveElkPreset('modelOrder').cycleBreaking).toBe('GREEDY_MODEL_ORDER'); + expect(resolveElkPreset('legacy').cycleBreaking).toBe('GREEDY'); + }); + + it('keeps depthFirst as a name for what default already is', () => { + // Not a distinct combination — a label, so a diagram can say depth-first + // rather than depend on the default staying put. + expect(resolveElkPreset('depthFirst')).toEqual(resolveElkPreset('default')); + }); + + it('differs from default in cycle breaking alone for modelOrder', () => { + const { cycleBreaking: _a, ...restDefault } = resolveElkPreset('default'); + const { cycleBreaking: _b, ...restModelOrder } = resolveElkPreset('modelOrder'); + expect(restModelOrder).toEqual(restDefault); + }); + + it('falls back to default for an unknown name, including __proto__', () => { + expect(resolveElkPreset('nope')).toEqual(resolveElkPreset('default')); + expect(resolveElkPreset('__proto__')).toEqual(resolveElkPreset('default')); + }); }); diff --git a/packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts b/packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts new file mode 100644 index 00000000000..9e398fe5bd8 --- /dev/null +++ b/packages/mermaid-layout-elk/src/elkOptionCatalogue.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + EDGE_ROUTING_OPTIONS, + PLACEMENT_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES, + SUBGRAPH_EXPERIMENT_OVERRIDES, +} from './elkOptionCatalogue.js'; + +/** + * Every catalogue block is merged last over the shipping layout options, so an + * option left uncommented changes every ELK diagram for every user. That is the + * point while an option is being tried, and a release blocker afterwards. + * + * These assertions are the "off" half of the switch: leave one on and the build + * fails here, naming the keys, rather than the change reaching a release as a + * silent rendering diff. + */ +describe('ELK option catalogue', () => { + const blocks = { + PLACEMENT_OPTIONS, + EDGE_ROUTING_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES, + SUBGRAPH_EXPERIMENT_OVERRIDES, + }; + + for (const [name, block] of Object.entries(blocks)) { + it(`ships with nothing switched on in ${name}`, () => { + expect( + Object.keys(block), + 're-comment these in elkOptionCatalogue.ts before committing' + ).toEqual([]); + }); + } +}); diff --git a/packages/mermaid-layout-elk/src/elkOptionCatalogue.ts b/packages/mermaid-layout-elk/src/elkOptionCatalogue.ts new file mode 100644 index 00000000000..954f57b216a --- /dev/null +++ b/packages/mermaid-layout-elk/src/elkOptionCatalogue.ts @@ -0,0 +1,292 @@ +/** + * A catalogue of the ELK options that affect layout, with every valid value + * listed and one line saying what it does. + * + * THIS FILE IS LIVE. Uncomment an option below and it applies to every ELK + * diagram on the next rebuild — no edit to `render.ts`, nothing to paste + * anywhere. Re-comment it to switch it back off. That is the whole workflow. + * + * Each block is merged over the shipping `layoutOptions` as the LAST word, so + * an entry beats the `elk.preset`, any `config.elk.*` key the diagram sets, and + * the DDLT sweep. Each block has exactly ONE destination: + * + * PLACEMENT_OPTIONS ─┐ + * EDGE_ROUTING_OPTIONS ├─→ root graph (`createRootElkGraph`) + * ROOT_EXPERIMENT_OVERRIDES ─┘ + * SUBGRAPH_EXPERIMENT_OVERRIDES → every container (`buildSubgraphLayoutOptions`) + * + * Root is right for layering, node placement, cycle breaking and edge routing: + * `elk.hierarchyHandling` is `INCLUDE_CHILDREN`, so one pass governs nodes + * inside frames too. It is WRONG for `spacing.*`, `elk.padding` and + * `nodeLabels.placement` — containers get their own set, so those do nothing at + * the root and belong in `SUBGRAPH_EXPERIMENT_OVERRIDES`. Several options here + * were written off as "no effect" before anyone noticed that. + * + * Do not merge a block into both sides. The shipping config gives containers a + * DIFFERENT node placement from the root (`preset.containerPlacement` vs + * `preset.placement`); forcing one value on both measures a layout the product + * can never produce. + * + * Two things to know before reading a result: + * + * - Most keys are listed several times, once per valid value. Uncomment TWO + * lines of the same key and it is a duplicate-key error, which is the + * intended guard rather than a silent last-one-wins. + * - Anything here that names a key already wired to `config.elk.*` silently + * disables that config for every diagram while it is live. `elk.cycleBreakingStrategy` + * was dead this way, and it took a bisect against the raw ELK option to spot. + * + * Everything here MUST be commented out on `develop`. `elkOptionCatalogue.spec.ts` + * asserts that, so an option left switched on fails the build instead of + * shipping as a silent rendering change for every user. + */ + +export const PLACEMENT_OPTIONS: Record = { + // ─── Layering — which layer a node lands in (the column in LR, row in TB) ─── + // Coarsest placement decision there is; relocates 38-48% of nodes. + // 'elk.layered.layering.strategy': 'COFFMAN_GRAHAM', + // 'elk.layered.layering.strategy': 'NETWORK_SIMPLEX', // ELK default: fewest long edges * + // 'elk.layered.layering.strategy': 'LONGEST_PATH', // every node as late as possible + // 'elk.layered.layering.strategy': 'LONGEST_PATH_SOURCE', // same, measured from sources + // 'elk.layered.layering.strategy': 'MIN_WIDTH', // narrower drawing, longer edges + // 'elk.layered.layering.strategy': 'STRETCH_WIDTH', // wider drawing, shorter edges + // 'elk.layered.layering.strategy': 'INTERACTIVE', // honours positions already on nodes + // Cap on how many nodes COFFMAN_GRAHAM puts in one layer; ignored by the rest. + // 'elk.layered.layering.coffmanGraham.layerBound': 2, + // 'elk.layered.layering.coffmanGraham.layerBound': 4, // ELK default; taller and narrower + // Pulls nodes into earlier layers to cut dummy nodes on long edges. + // 'elk.layered.layering.nodePromotion.strategy': 'NONE', // ELK default + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV', + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV_PIXEL', + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV_IMPROVED', + // 'elk.layered.layering.nodePromotion.strategy': 'NIKOLOV_IMPROVED_PIXEL', + // 'elk.layered.layering.nodePromotion.strategy': 'DUMMYNODE_PERCENTAGE', + // 'elk.layered.layering.nodePromotion.strategy': 'NODECOUNT_PERCENTAGE', + // 'elk.layered.layering.nodePromotion.strategy': 'NO_BOUNDARY', + // ─── Crossing minimisation — the order of nodes within a layer ─── + // How node order inside each layer is chosen. + // 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', // ELK default + //'elk.layered.crossingMinimization.strategy': 'INTERACTIVE', // keeps existing order + // 'elk.layered.crossingMinimization.strategy': 'NONE', // declaration order, no sweep + // Extra pass that swaps adjacent node pairs when it removes crossings. + // 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', // ELK default + // 'elk.layered.crossingMinimization.greedySwitch.type': 'ONE_SIDED', + // 'elk.layered.crossingMinimization.greedySwitch.type': 'OFF', + // How hard declaration order is defended against crossing reduction. + // 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + // 'elk.layered.considerModelOrder.strategy': 'NONE', // ignore declaration order + // 'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES', // order edges, let nodes move + // 'elk.layered.considerModelOrder.strategy': 'PREFER_NODES', // order nodes, let edges move + // ─── Node placement — the coordinate within the layer ─── + // Wired to `elk.nodePlacementStrategy`; uncomment to override that config. + // 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', // our default: balanced + // 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', // ELK default: straight long edges + // 'elk.layered.nodePlacement.strategy': 'LINEAR_SEGMENTS', // keeps chains aligned + // 'elk.layered.nodePlacement.strategy': 'SIMPLE', // cheapest, least tidy + // Shifts nodes to straighten edges rather than centre them in the layer. + // 'elk.layered.nodePlacement.favorStraightEdges': true, + // 'elk.layered.nodePlacement.favorStraightEdges': false, + // Brandes-Koepf only: which of its four candidate alignments to keep. + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'NONE', // pick the shortest result + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // average all four + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'LEFTUP', + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'RIGHTUP', + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'LEFTDOWN', + // 'elk.layered.nodePlacement.bk.fixedAlignment': 'RIGHTDOWN', + // Brandes-Koepf only: post-pass that trades compactness for straighter edges. + // 'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS', + // 'elk.layered.nodePlacement.bk.edgeStraightening': 'NONE', // ELK default + // Network-simplex only: what the placer is allowed to stretch to straighten edges. + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NONE', // ELK default + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE', + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'PORT_POSITION', + // 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE_WHERE_SPACE_PERMITS', + // ─── Cycles and hierarchy ─── + // Which edges get reversed to make the graph acyclic; decides which ones detour. + // Wired to `elk.cycleBreakingStrategy`; uncomment to override that config. + // 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER', // our default + // 'elk.layered.cycleBreaking.strategy': 'GREEDY', // ELK default; short back edges, +20% total + // 'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST', // middle ground, +6% total + // 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER', // reverse purely by declaration order + // 'elk.layered.cycleBreaking.strategy': 'INTERACTIVE', // reverse by existing positions + // Whether subgraphs are laid out with the parent or in their own coordinate system. + // 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', // our default, one global pass + // 'elk.hierarchyHandling': 'SEPARATE_CHILDREN', // shorter edges, far more constraint violations + // Post-pass that pulls nodes back towards one side to reclaim space. + // 'elk.layered.compaction.postCompaction.strategy': 'NONE', // ELK default + // 'elk.layered.compaction.postCompaction.strategy': 'LEFT', + // 'elk.layered.compaction.postCompaction.strategy': 'RIGHT', + // 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONSTRAINT_LOCKING', + // 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONNECTION_LOCKING', + // 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH', + // ─── Spacing and labels ─── + // Base spacing everything else derives from; the single biggest lever on size. + // 'spacing.baseValue': 40, + // 'spacing.baseValue': 20, // ELK default — collapses this corpus, 13/14 invalid + // Where a container's own title sits inside its frame. + // 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', + // ─── Measured inert on this corpus — a null result here means nothing ─── + // Overwritten straight after createRootElkGraph by the diagram's own direction. + // 'elk.direction': 'UP', + // ELK ignores this key in every spelling; the gap derives from spacing.baseValue. + // 'elk.spacing.edgeNode': 20, + // Only applies when wrapping.strategy is on, and it is off. + // 'elk.layered.wrapping.cutting.strategy': 'ARD', + // Routes reversed edges in their own band. No effect measured here. + // 'elk.layered.feedbackEdges': true, + // ─── Tried and parked ─── + // 'elk.layered.wrapping.strategy': 'MULTI_EDGE', + // 'elk.layered.wrapping.strategy': 'SINGLE_EDGE', + // 'elk.layered.crossingMinimization.semiInteractive': true, + // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1, + // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0, + // 'elk.layered.wrapping.validify.strategy': 'LOOK_BACK', + // 'elk.insideSelfLoops.activate': true, + // 'elk.separateConnectedComponents': true, + // 'elk.alignment': 'LEFT', +}; + +/** + * Edge ROUTING options. Live, on the same terms as {@link PLACEMENT_OPTIONS} — + * merged over root and subgraph alike, and spread after it so these win. + * + * Routing decides how an edge is drawn between the layers it was already + * assigned to. It cannot change which way round the graph an edge travels — a + * long detour is a back edge, and that is settled in cycle breaking and + * layering, both of which live in {@link PLACEMENT_OPTIONS}. + * + * MUST be fully commented out on `develop`. The routing options actually in + * force ship in `createRootElkGraph`: `edgeRouting.selfLoopDistribution`, + * `unnecessaryBendpoints` and `mergeHierarchyEdges`. + */ +export const EDGE_ROUTING_OPTIONS: Record = { + // Shape of every edge. ORTHOGONAL is ELK's default and what the adapter expects. + // 'elk.edgeRouting': 'ORTHOGONAL', + // 'elk.edgeRouting': 'POLYLINE', // diagonal runs, fewer bends + // 'elk.edgeRouting': 'SPLINES', // curved; validateLayout treats these as non-orthogonal + // 'elk.edgeRouting': 'UNDEFINED', // let the algorithm decide + // Drops bends that do not change the path. Already on in the literal below. + // 'elk.layered.unnecessaryBendpoints': true, + // 'elk.layered.unnecessaryBendpoints': false, + // Routes reversed edges in their own band instead of among the forward ones. + // The obvious candidate for a back-edge detour — measured inert on this corpus. + // 'elk.layered.feedbackEdges': true, + // 'elk.layered.feedbackEdges': false, + // Lets edges that meet at a node share a trunk. Collapses arriving and leaving + // onto ONE handle, which can imply a connection that does not exist. + // 'elk.layered.mergeEdges': true, + // 'elk.layered.mergeEdges': false, + // Same, for edges that cross a subgraph boundary. On in the literal below. + // 'elk.layered.mergeHierarchyEdges': true, + // 'elk.layered.mergeHierarchyEdges': false, + // How much straightening an edge is worth relative to other objectives. + // Also settable per edge, which is the targeted way to rescue one bad route. + // 'elk.layered.priority.straightness': 0, + // 'elk.layered.priority.shortness': 0, + // 'elk.layered.priority.direction': 1, + // ─── Self loops ─── + // Which sides a node's self loops are spread across. EQUALLY ships below. + // 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY', + // 'elk.layered.edgeRouting.selfLoopDistribution': 'NORTH', + // 'elk.layered.edgeRouting.selfLoopDistribution': 'NORTH_SOUTH', + // Whether stacked self loops nest or sit side by side. + // 'elk.layered.edgeRouting.selfLoopOrdering': 'STACKED', + // 'elk.layered.edgeRouting.selfLoopOrdering': 'SEQUENCED', + // Draw self loops inside the node rather than hanging off it. + // 'elk.insideSelfLoops.activate': true, + // ─── Spline and polyline tuning (only read by the matching edgeRouting) ─── + // How closely splines hug the orthogonal path they replace. + // 'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE', + // 'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE_SOFT', + // 'elk.layered.edgeRouting.splines.mode': 'SLOPPY', + // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1, + // Width of the band a POLYLINE edge may slope through. + // (Was left uncommented while this block was inert; commented now that it is + // live, since it would otherwise be permanently on for every diagram.) + // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0, + // ─── Lanes and clearance ─── + // Gap between two edges sharing a lane; too small trips the proximity checks. + // 'spacing.edgeEdge': 10, + // 'elk.layered.spacing.edgeEdgeBetweenLayers': 20, + // Gap between an edge and a node it passes. Ignored at root; the subgraph + // value derives from `spacing.baseValue` at roughly half. + // 'spacing.edgeNode': 20, + // 'elk.layered.spacing.edgeNodeBetweenLayers': 80, + // ─── Edge labels ─── + // Which side of its edge a label sits on. + // 'elk.layered.edgeLabels.sideSelection': 'SMART_DOWN', + // 'elk.layered.edgeLabels.sideSelection': 'SMART_UP', + // 'elk.layered.edgeLabels.sideSelection': 'ALWAYS_UP', + // 'elk.layered.edgeLabels.sideSelection': 'ALWAYS_DOWN', + // 'elk.layered.edgeLabels.sideSelection': 'DIRECTION_UP', + // 'elk.layered.edgeLabels.sideSelection': 'DIRECTION_DOWN', + // Which layer a centre label is parked in when the edge spans several. + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'MEDIAN_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'HEAD_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'TAIL_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'SPACE_EFFICIENT_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'WIDEST_LAYER', + // 'elk.layered.edgeLabels.centerLabelPlacementStrategy': 'CENTER_LAYER', +}; + +/* ──────────────────────────────────────────────────────────────────────────── + * THE SWITCH + * + * Everything above is reference material — inert, imported by nothing, there to + * be read and copied from. The two objects below are the opposite: they ARE + * imported, and whatever they contain is merged over the shipping layout + * options as the last word. That is the on/off switch. + * + * To try an option: copy its line out of the catalogue above into the matching + * object below and uncomment it. To switch back off: re-comment it. Nothing in + * `render.ts` needs editing either way, so an experiment can never be left + * behind as a stray edit in production code — which is how the previous round + * of these ended up deleted rather than kept. + * + * Both MUST be empty on `develop`. `elkOptionCatalogue.spec.ts` asserts exactly + * that, so an override left switched on fails the build instead of shipping. + * + * Which object to use matters more than it looks: options set on the ROOT graph + * do NOT reach subgraphs — containers get their own set — so `spacing.*`, + * `elk.padding` and `nodeLabels.placement` do nothing at the root. Several + * options in the catalogue above were written off as "no effect" until they + * were moved to the subgraph side. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Scratch overrides merged last over the ROOT graph's `layoutOptions`, after + * `elk.preset` and every `config.elk.*` key have been resolved — so an entry + * here beats the shipping default AND the diagram's own config. + * + * Governs layering, node placement, cycle breaking and edge routing for the + * top-level graph. MUST be empty on `develop`. + */ +export const ROOT_EXPERIMENT_OVERRIDES: Record = { + // 'elk.layered.layering.strategy': 'COFFMAN_GRAHAM', + // 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', + // 'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST', + // 'elk.edgeRouting': 'POLYLINE', + // 'spacing.baseValue': 60, +}; + +/** + * Scratch overrides merged last over every SUBGRAPH's `layoutOptions`, after + * the per-container algorithm branch — so an entry here also beats the + * rectpacking and directional-subgraph blocks. + * + * This is the side that owns spacing, padding and label placement inside a + * frame. MUST be empty on `develop`. + */ +export const SUBGRAPH_EXPERIMENT_OVERRIDES: Record = { + // 'spacing.nodeNode': 60, + // 'elk.spacing.edgeEdge': 10, + // 'elk.layered.spacing.edgeNodeBetweenLayers': 80, + // 'elk.padding': '[top=24,left=24,bottom=24,right=24]', + // 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', + // Equal-width subgraph frames. Tried and DOES NOT WORK: the options reach + // ELK intact, but every container is forced back to INCLUDE_CHILDREN by + // `setIncludeChildrenPolicy` (cross-boundary edges), and in that mode ELK + // sizes a compound node to its contents and ignores the minimum. + // 'nodeSize.constraints': '[MINIMUM_SIZE]', + // 'nodeSize.minimum': '(446, 0)', +}; diff --git a/packages/mermaid-layout-elk/src/geometry.ts b/packages/mermaid-layout-elk/src/geometry.ts index 5cda865debe..b8f2afb6537 100644 --- a/packages/mermaid-layout-elk/src/geometry.ts +++ b/packages/mermaid-layout-elk/src/geometry.ts @@ -162,6 +162,117 @@ export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P return intersection(bounds, outside, inside); }; +/** + * Bisection steps used to walk a ray onto the node outline. + * + * Each step halves the bracket and costs one `node.intersect()` call, and both + * endpoints of every edge run this — so the count is paid twice per edge. 20 + * steps take a 200px starting bracket to about 2e-4px, which is four orders of + * magnitude below anything that can be rendered; going further only buys + * precision that the SVG coordinate is rounded away from anyway. + */ +const OUTLINE_RAY_STEPS = 20; + +/** + * How far off an axis a departure may sit and still count as axis-aligned. + * + * ELK's orthogonal routing emits exact horizontal and vertical stubs, so this + * only has to absorb floating-point dust, not a tolerance for near-diagonals. + */ +const DEPARTURE_AXIS_EPS = 1e-6; + +/** + * Whether a point lies inside the node's outline. + * + * Derived from `intersect` alone, so it needs no per-shape knowledge: the + * shape's `intersect` returns where the ray from the node CENTRE through the + * probe leaves the outline, so the probe is inside exactly when it is no + * further from the centre than that crossing is. Valid for any outline that is + * star-shaped about its centre, which every built-in shape is. + */ +const insideOutline = (node: NodeLike, centre: P, probe: P): boolean => { + const crossing = node.intersect?.(probe); + if (!crossing) { + return false; + } + const probeDist = Math.hypot(probe.x - centre.x, probe.y - centre.y); + const outlineDist = Math.hypot(crossing.x - centre.x, crossing.y - centre.y); + return probeDist <= outlineDist + 1e-9; +}; + +/** + * Where the node's outline meets the ray that runs into the node from `port`, + * against the direction the edge departs in. + * + * ELK routes to ports on the node's BOUNDING BOX, and always leaves one + * perpendicular to the side it sits on. For a rectangle that port is already + * the attachment point. For anything else the outline is inside the box, so the + * attachment has to move inwards — and the direction it moves in decides + * whether the edge stays orthogonal. + * + * Moving along the centre ray (what `intersect` does on its own) lands on the + * outline at a DIFFERENT offset along the side than the port, so the opening + * segment comes out diagonal and the edge visibly kinks as it leaves the shape. + * Moving along the departure axis instead keeps the attachment collinear with + * ELK's own stub: the edge leaves the outline, crosses the box, and carries on + * in one straight line. + * + * Returns null when the ray cannot be resolved — no `intersect`, a departure + * direction that is not axis-aligned, or an interior sample that is not + * actually inside — leaving the caller on its existing path. + */ +export const outlineAttachPoint = ( + node: NodeLike, + bounds: RectLike, + port: P, + next: P +): P | null => { + if (!node?.intersect) { + return null; + } + + const dx = next.x - port.x; + const dy = next.y - port.y; + if (dx === 0 && dy === 0) { + return null; + } + + // A diagonal departure has no single axis to preserve. The bisection below + // walks along one axis holding the other fixed, so forcing a diagonal onto + // its dominant axis would attach at a point the edge does not actually pass + // through — reintroducing the offset this function exists to remove. Decline + // instead, and let the caller fall back to the centre-ray intersection. + if (Math.abs(dx) > DEPARTURE_AXIS_EPS && Math.abs(dy) > DEPARTURE_AXIS_EPS) { + return null; + } + + const centre = { x: bounds.x, y: bounds.y }; + const horizontal = Math.abs(dx) > Math.abs(dy); + const along = (t: number): P => (horizontal ? { x: t, y: port.y } : { x: port.x, y: t }); + + // Walk in from the centre-line towards the port: inside at one end, on or + // outside the outline at the other. + let inner = horizontal ? centre.x : centre.y; + let outer = horizontal ? port.x : port.y; + if (!insideOutline(node, centre, along(inner))) { + return null; + } + if (insideOutline(node, centre, along(outer))) { + // The port itself is on or inside the outline — it IS the attachment. + return { ...port }; + } + + for (let step = 0; step < OUTLINE_RAY_STEPS; step++) { + const mid = (inner + outer) / 2; + if (insideOutline(node, centre, along(mid))) { + inner = mid; + } else { + outer = mid; + } + } + return along(inner); +}; + export const computeNodeIntersection = ( node: NodeLike, bounds: RectLike, diff --git a/packages/mermaid-layout-elk/src/lineHops.ts b/packages/mermaid-layout-elk/src/lineHops.ts new file mode 100644 index 00000000000..4d7f8bc94c4 --- /dev/null +++ b/packages/mermaid-layout-elk/src/lineHops.ts @@ -0,0 +1,69 @@ +import { + applyLineJumpsToSvg, + type CommonLayoutPaintContext, + type EdgeGeom, + type LayoutData, +} from 'mermaid'; +/** + * Radius of the arc drawn where one edge hops another. + * + * Fixed rather than configurable: a hop reads as a hop because every one in the + * diagram is the same size, and `lineJump` already shrinks or drops an + * individual arc where a bend leaves it no room. `elk.lineHops` therefore + * exposes the STYLE (`arc` or `gap`) but not the radius, since a per-diagram + * radius would be a knob whose only good value is this one. + */ +const JUMP_RADIUS = 6; + +/** + * The paint groups `applyElkLineJumps` needs off the measure context. + * + * The selection type is taken from `applyLineJumpsToSvg`'s own signature rather + * than named here: `D3Selection` is not part of mermaid's public surface, and + * deriving it keeps the two in step without widening that surface. + */ +interface EdgePaintGroups { + groups: { edgePaths: Parameters[0] }; +} + +/** + * Draw a hop where two edges cross. + * + * Runs as `afterPaint`, because a hop is a property of the rendered path rather + * than of the layout: the crossings are only known once every edge has been + * emitted, and the fix is to rewrite the `d` of the edge that gives way. + * + * ELK's curve is compatible either way — `applyElkEdgeLayout` sets `rounded` + * for a routed edge and `linear` for its straight-line fallback, and + * `curveSupportsLineHops` accepts both. An edge that takes a hop loses its + * corner rounding in exchange, which is the trade the line-jump module + * documents. + */ +export function applyElkLineJumps( + data4Layout: LayoutData, + { measure }: CommonLayoutPaintContext +): void { + const lineHops = (data4Layout.config as { elk?: { lineHops?: boolean | string } })?.elk?.lineHops; + if (lineHops === false) { + return; + } + + const edgeGeometries: EdgeGeom[] = data4Layout.edges + .filter( + (edge): edge is typeof edge & { points: EdgeGeom['points'] } => + Array.isArray(edge.points) && edge.points.length >= 2 + ) + .map((edge) => ({ + id: edge.id, + points: edge.points, + curve: edge.curve, + arrowTypeStart: edge.arrowTypeStart, + arrowTypeEnd: edge.arrowTypeEnd, + })); + + applyLineJumpsToSvg(measure.groups.edgePaths, edgeGeometries, { + enabled: true, + jumpRadius: JUMP_RADIUS, + jumpStyle: lineHops === 'gap' ? 'gap' : 'arc', + }); +} diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 1f35abd7b7a..485347b4ebe 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -8,12 +8,20 @@ import mermaid, { import { curveLinear } from 'd3'; import ELK from 'elkjs/lib/elk.bundled.js'; import { type TreeData, findCommonAncestor } from './find-common-ancestor.js'; +import { applyElkLineJumps } from './lineHops.js'; +import { + EDGE_ROUTING_OPTIONS, + PLACEMENT_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES, + SUBGRAPH_EXPERIMENT_OVERRIDES, +} from './elkOptionCatalogue.js'; import { type P, type RectLike, outsideNode, computeNodeIntersection, + outlineAttachPoint, replaceEndpoint, onBorder, } from './geometry.js'; @@ -43,6 +51,11 @@ interface NodeWithVertex { height?: number; intersect?: (point: P) => P | null; isGroup?: boolean; + /** + * Where ELK put this container, kept when `evenGroupFrames` moves the drawn + * frame. Edge sections resolve against this, never against the moved frame. + */ + elkOrigin?: { posX: number; posY: number }; padding?: number; parentId?: string; shape?: string; @@ -59,6 +72,10 @@ interface NodeWithVertex { interface ElkSubgraphConfig { mergeEdges?: boolean; + straightenEdges?: boolean; + preset?: string; + layeringStrategy?: string; + layeringLayerBound?: number; nodePlacementAlignment?: string; nodePlacementStrategy?: string; } @@ -69,6 +86,23 @@ interface ElkPreparedLayout { interface ElkLayoutContext { algorithm?: string; + /** + * Extra root-graph `layoutOptions`, merged last over + * {@link createRootElkGraph}'s defaults. + * + * NOT user-facing config: nothing in `config.schema.yaml` writes it and + * production `render()` never sets it. It exists so the DDLT configuration + * sweep can try ELK options that are currently hardcoded here — spacings, + * edge routing, node placement — WITHOUT forking the layout pipeline. A + * sweep that reimplemented `createRootElkGraph` would be measuring a graph + * the browser never builds, which is the exact failure the single-pipeline + * rule exists to prevent. + * + * Promote a winning option to a real default in `createRootElkGraph`, or to + * a `config.elk.*` key if it should be author-controlled. Do not reach for + * this from product code. + */ + rootLayoutOptions?: Record; common: { lineBreakRegex: RegExp }; getConfig: () => any; interpolateToCurve: (interpolate: string | undefined, defaultCurve: unknown) => unknown; @@ -108,8 +142,51 @@ const ARROW_MAP: Record = { double_arrow_circle: ['arrow_circle', 'arrow_circle'], }; const DEFAULT_NODE_PLACEMENT_ALIGNMENT = 'NONE'; -/** Default `spacing.baseValue` for a subgraph that has no algorithm of its own. */ -const DEFAULT_SUBGRAPH_SPACING_BASE_VALUE = 30; + +/** + * Margin reserved at the ends of each side of a node, so that a port cannot be + * placed on a corner. Spelled as an ELK margin because `spacing.portsSurrounding` + * takes one. + */ +const PORTS_SURROUNDING = '[top=12,left=12,bottom=12,right=12]'; +/** Padding between a subgraph frame and its children. ELK's own default is 12. */ +const SUBGRAPH_PADDING = 24; +/** + * Default `spacing.baseValue` for a subgraph that has no algorithm of its own. + * + * Every unset spacing derives from this, which is why it used to be 50: the + * gap ELK derives for an edge approaching a node comes out at roughly half, + * and below about 40 the approach ran shorter than the 10px arrowhead, so the + * turn read as happening underneath it. + * + * Paying for that approach out of the base value overcharged everything else. + * An edge routed down the inside of a frame claims a lane the same width, so a + * group with a couple of them was pushed 50px clear of its own border on that + * side and nowhere else — visible as a subgraph padded on one side only, for + * no reason a reader can see. + * + * The two are now set separately: this stays tight, and + * `elk.layered.spacing.edgeNodeBetweenLayers` buys the approach on its own. + * An earlier note here claimed ELK ignored an explicit edge-node spacing "in + * every key form"; it does honour the layered-scoped key, and the attempt that + * failed had used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is + * edge-to-edge and a different quantity. + */ +const DEFAULT_SUBGRAPH_SPACING_BASE_VALUE = 24; +/** + * Gap between two sibling nodes in a subgraph. + * + * Also used to derive from `spacing.baseValue`, so lowering that pulled a + * group's nodes together until they tripped the validator's + * `node-node-padding` rule — three fixtures went invalid on it. 50 is what the + * old base value yielded, restored here so the base value is free to be small. + * + * Deliberately spelled the same way as the `elk.rectpacking` override in + * `RECTPACKING_OPTIONS`: ELK reads `spacing.nodeNode` and `elk.spacing.nodeNode` + * as the same option, so using both forms would leave a rectpacking container + * carrying two values for it and no say in which one won. + */ +const DEFAULT_SUBGRAPH_NODE_SPACING = 50; /** Inner padding reserved around a container that runs its own algorithm. */ const CONTAINER_PADDING = 15; /** Same, for `elk.rectpacking`, which packs tighter. */ @@ -160,9 +237,12 @@ export function clearContainerAlgorithmOptions(layoutOptions: Record = { 'spacing.baseValue': DEFAULT_SUBGRAPH_SPACING_BASE_VALUE, + // The straight run an edge gets before the node it enters, bought on its + // own rather than out of `spacing.baseValue` — see the note there. This is + // the layered-scoped key; the unscoped `spacing.edgeNodeBetweenLayers` is + // not an ELK id at all and setting it does nothing. + // + // 30, which is where the approach run stops improving: 40 measured the same + // 30px shortest approach and only widened the lane this value also pays + // for. That lane used to be the reason to go lower — the value is charged + // TWICE against a group with an edge routed inside its frame, once between + // the nodes and the lane and again between the lane and the frame, so the + // group's extra width came out at exactly `36 + 2x`. `evenGroupFrames` now + // pulls the frame in past the lane regardless, so a wider lane no longer + // shows as lopsided padding and the only cost left is overall diagram size. + // + // Do NOT lower it further on that reasoning. Over the DDLT corpus this is + // not monotonic: 30 and 40 leave one fixture invalid (the deliberate + // merge-edge counterexample), while 20 leaves two and 25 leaves three — + // `right-angles-not-curves` starts tripping `edge-parallel-segment-too-close` + // because this spacing also separates edges running alongside each other in + // the layer gap. 30 is the lowest value that keeps the corpus clean. + 'elk.layered.spacing.edgeNodeBetweenLayers': 30, + // Separation between edges sharing a lane. Also raised off the base value, + // so that lowering the base does not leave parallel edges touching. + 'elk.spacing.edgeEdge': 20, + // Node separation, likewise bought on its own — see the note on the constant. + 'spacing.nodeNode': DEFAULT_SUBGRAPH_NODE_SPACING, + // Breathing room between a frame and its children. Set explicitly rather + // than left to ELK's default of 12. The top gets the same value as the + // rest: ELK reserves the subgraph's own title strip on top of whatever is + // given here, so adding the label height again double-counts it. + 'elk.padding': `[top=${SUBGRAPH_PADDING},left=${SUBGRAPH_PADDING},bottom=${SUBGRAPH_PADDING},right=${SUBGRAPH_PADDING}]`, 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]', - 'nodePlacement.strategy': elkConfig?.nodePlacementStrategy, + 'elk.layered.mergeEdges': elkConfig?.mergeEdges, 'elk.layered.nodePlacement.bk.fixedAlignment': elkConfig?.nodePlacementAlignment ?? DEFAULT_NODE_PLACEMENT_ALIGNMENT, + // Containers place their own children, and the preset says how: by default + // NETWORK_SIMPLEX, which balances a node against all of its neighbours and + // so keeps a group's nodes aligned with each other instead of drifting, + // while the root uses LINEAR_SEGMENTS. `legacy` keeps both on the strategy + // that shipped before, so it still reproduces the old rendering. + // + // ONE key, fully qualified. ELK reads `nodePlacement.strategy` and + // `elk.layered.nodePlacement.strategy` as the same option, so listing both + // — as this did — leaves the container holding two values for it with no + // say in which wins, and quietly ignores an explicit `nodePlacementStrategy`. + 'elk.layered.nodePlacement.strategy': + elkConfig?.nodePlacementStrategy ?? resolveElkPreset(elkConfig?.preset).containerPlacement, + // PORT_POSITION lets a node shift so an edge can leave straight rather than + // bending immediately off the port. + 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'PORT_POSITION', + // Keep a frame's ports off its own corners. See the note in + // `createRootElkGraph`; a container is where this bites hardest, because a + // cross-boundary edge attaches to the frame rather than to a node inside it. + 'elk.spacing.portsSurrounding': PORTS_SURROUNDING, }; // Apply per-group algorithm from metadata (e.g. @{algorithm: elk.box}). @@ -286,6 +416,11 @@ export function buildSubgraphLayoutOptions( layoutOptions['elk.direction'] = dir2ElkDirection(node.dir); layoutOptions['elk.hierarchyHandling'] = 'SEPARATE_CHILDREN'; } + + // Container-scoped experiments. Spacing, padding and label placement only + // bite here — an option set on the root never reaches inside a frame. + Object.assign(layoutOptions, SUBGRAPH_EXPERIMENT_OVERRIDES); + return layoutOptions; } @@ -496,7 +631,11 @@ export function buildElkGraphFromLayoutData( elkContext: ElkLayoutContext ): ElkLayoutState { const nodeDb: Record = {}; - const elkGraph = createRootElkGraph(data4Layout, elkContext.algorithm); + const elkGraph = createRootElkGraph( + data4Layout, + elkContext.algorithm, + elkContext.rootLayoutOptions + ); const dir = (data4Layout as { direction?: string }).direction ?? 'DOWN'; elkGraph.layoutOptions['elk.direction'] = dir2ElkDirection(dir); @@ -512,6 +651,7 @@ export function buildElkGraphFromLayoutData( } export const render = createCommonLayoutRenderer({ + afterPaint: applyElkLineJumps, prepareLayout: prepareLayoutForElk, // ELK derives a compound node's minimum size from the measured cluster label, // so the label has to be measured the way `insertCluster` paints it — @@ -579,6 +719,9 @@ function getElkLayoutContext( algorithm: context.preparedLayout?.algorithm ?? (context.options as { algorithm?: string } | undefined)?.algorithm, + rootLayoutOptions: ( + context.options as { rootLayoutOptions?: Record } | undefined + )?.rootLayoutOptions, common: helpers.common, getConfig: helpers.getConfig, interpolateToCurve: helpers.interpolateToCurve as ( @@ -589,27 +732,139 @@ function getElkLayoutContext( }; } -function createRootElkGraph(data4Layout: LayoutData, algorithm: string | undefined): any { +/** + * Scratch overrides for local experimentation. MUST be empty on `develop`. + * + * Spread last into the root graph's `layoutOptions`, so anything here wins over + * the defaults above — including the keys wired to `config.elk.*`. That is the + * point: edit one line, let the dev server rebuild, and compare renders without + * touching a diagram's frontmatter or the config schema. + * + * It is also why this must not ship. An entry here silently disables the + * matching user-facing option for every diagram, and the symptom — "this config + * key does nothing" — gives no hint where to look. `elk.cycleBreakingStrategy` + * was dead this way, and it took a bisect against the raw ELK option to notice. + */ +/** + * Named combinations of the three options that decide where nodes end up. + * + * Layering picks the column, node placement the coordinate within it, and cycle + * breaking which edges are reversed and therefore which ones detour. They run in + * different phases and do not interact, so a preset is a named triple rather + * than a mode of its own. + * + * An explicit `elk.layeringStrategy` / `nodePlacementStrategy` / + * `cycleBreakingStrategy` beats the preset for that one option — which is why + * `defaultConfig` leaves all three undefined rather than giving them values. + */ +const ELK_PRESETS: Record< + string, + { layering: string; placement: string; containerPlacement: string; cycleBreaking: string } +> = { + /** + * Network simplex at the root, Brandes-Koepf inside frames, cycles broken + * depth first. + * + * Depth-first cycle breaking gives shorter back edges on graphs that have + * many of them, which is most flowcharts that loop at all. `modelOrder` is + * the same triple with the greedy-model-order breaking this used to carry. + * + * Containers deliberately do NOT follow the root's placement. Network simplex + * inside a frame produced routes that left a subgraph on its bounding-box + * corner, so the two sides are tuned separately: changing one is not a reason + * to change the other, and `legacy` keeps both on the strategy that shipped + * before. + */ + default: { + layering: 'NETWORK_SIMPLEX', + placement: 'NETWORK_SIMPLEX', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'DEPTH_FIRST', + }, + /** + * What shipped before presets: straighter long edges, less alignment. + * + * `GREEDY`, not `GREEDY_MODEL_ORDER`, is deliberate. The schema advertised + * the latter, but `defaultConfig` never listed `cycleBreakingStrategy`, so it + * reached ELK as undefined and ELK's own default applied. This preset + * reproduces what `develop` actually renders, not what its schema claimed. + * Layering is ELK's default too — `develop` does not wire the option at all. + */ + legacy: { + layering: 'NETWORK_SIMPLEX', + placement: 'BRANDES_KOEPF', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'GREEDY', + }, + /** + * As `default`, but breaks cycles by greedy model order — which reverses the + * edges that disturb declaration order least, at the cost of longer back + * edges. This is the triple `default` named before depth-first took over. + */ + modelOrder: { + layering: 'NETWORK_SIMPLEX', + placement: 'NETWORK_SIMPLEX', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'GREEDY_MODEL_ORDER', + }, + /** + * Kept as a name for what `default` now is, so diagrams that asked for + * depth-first breaking by name keep saying what they mean. Identical to + * `default` on purpose — not a distinct combination. + */ + depthFirst: { + layering: 'NETWORK_SIMPLEX', + placement: 'NETWORK_SIMPLEX', + containerPlacement: 'BRANDES_KOEPF', + cycleBreaking: 'DEPTH_FIRST', + }, +}; + +/** + * Resolve a preset name, falling back to `default` for an unknown one. + * + * `Object.hasOwn` rather than a plain lookup: the schema's enum only guards the + * config path, and a directive or a programmatic config can still put anything + * here. `ELK_PRESETS['__proto__']` is truthy, so an indexed lookup would return + * `Object.prototype` and every strategy read off it would come back `undefined` + * — a silently strategy-less layout rather than the documented fallback. + */ +export function resolveElkPreset(name: string | undefined) { + return name !== undefined && Object.hasOwn(ELK_PRESETS, name) + ? ELK_PRESETS[name] + : ELK_PRESETS.default; +} + +function createRootElkGraph( + data4Layout: LayoutData, + algorithm: string | undefined, + rootLayoutOptions?: Record +): any { + const preset = resolveElkPreset(data4Layout.config.elk?.preset); const graph = { id: 'root', layoutOptions: { 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.algorithm': algorithm, - 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy, + 'elk.layered.nodePlacement.strategy': + data4Layout.config.elk?.nodePlacementStrategy ?? preset.placement, 'elk.layered.nodePlacement.bk.fixedAlignment': data4Layout.config.elk?.nodePlacementAlignment ?? DEFAULT_NODE_PLACEMENT_ALIGNMENT, 'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges, 'elk.direction': 'DOWN', 'spacing.baseValue': 40, + 'elk.layered.crossingMinimization.forceNodeModelOrder': data4Layout.config.elk?.forceNodeModelOrder, 'elk.layered.considerModelOrder.strategy': data4Layout.config.elk?.considerModelOrder, 'elk.layered.unnecessaryBendpoints': true, - 'elk.layered.cycleBreaking.strategy': data4Layout.config.elk?.cycleBreakingStrategy, + 'elk.layered.cycleBreaking.strategy': + data4Layout.config.elk?.cycleBreakingStrategy ?? preset.cycleBreaking, + 'elk.layered.layering.strategy': data4Layout.config.elk?.layeringStrategy ?? preset.layering, + // Only COFFMAN_GRAHAM reads this; the others ignore it. + 'elk.layered.layering.coffmanGraham.layerBound': data4Layout.config.elk?.layeringLayerBound, - // 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER', - // 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER', - // 'spacing.nodeNode': 20, + // 'spacing.nodeNode': 120, // 'spacing.nodeNodeBetweenLayers': 25, // 'spacing.edgeNode': 20, // 'spacing.edgeNodeBetweenLayers': 10, @@ -618,27 +873,22 @@ function createRootElkGraph(data4Layout: LayoutData, algorithm: string | undefin // 'spacing.nodeSelfLoop': 20, // Tweaking options - // 'nodePlacement.favorStraightEdges': true, - // 'elk.layered.nodePlacement.favorStraightEdges': true, - // 'nodePlacement.feedbackEdges': true, 'elk.layered.wrapping.multiEdge.improveCuts': true, 'elk.layered.wrapping.multiEdge.improveWrappedEdges': true, - // 'elk.layered.wrapping.strategy': 'MULTI_EDGE', - // 'elk.layered.wrapping.strategy': 'SINGLE_EDGE', 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY', 'elk.layered.mergeHierarchyEdges': true, - - // 'elk.layered.feedbackEdges': true, - // 'elk.layered.crossingMinimization.semiInteractive': true, - // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1, - // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0, - // 'elk.layered.wrapping.validify.strategy': 'LOOK_BACK', - // 'elk.insideSelfLoops.activate': true, - // 'elk.separateConnectedComponents': true, - // 'elk.alg.layered.options.EdgeStraighteningStrategy': 'NONE', - // 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', - // 'elk.layered.considerModelOrder.strategy': 'EDGES', - // 'elk.layered.wrapping.cutting.strategy': 'ARD', + // Reserve a margin at the ends of every side so a port cannot land on a + // corner. ELK's default is 0, which permits it — and a corner is the one + // boundary point with no side to leave from, so the edge came out of the + // vertex and then ran ALONG the box's own edge before turning away. It + // showed up on subgraphs first because a cross-boundary edge attaches to + // the frame, which is large enough for the corner to be visible. + // + // Chosen at 12 by measurement, not taste: it is the smallest value that + // clears the corner on the `elk-edge-cases` corpus. 30 was tried and + // reorders layers, so this is not a free parameter — raising it changes + // more than clearance. + 'elk.spacing.portsSurrounding': PORTS_SURROUNDING, }, children: [], edges: [], @@ -652,6 +902,22 @@ function createRootElkGraph(data4Layout: LayoutData, algorithm: string | undefin }); } + // Last, so a sweep override beats every default above. See + // `ElkLayoutContext.rootLayoutOptions` for why this exists. + if (rootLayoutOptions) { + Object.assign(graph.layoutOptions, rootLayoutOptions); + } + + // Hand-run experiments from `elkOptionCatalogue.ts`, last so an option + // switched on there wins over the preset, `config.elk.*` and the sweep. + // MUST all be commented out on `develop` — this is the production path. + Object.assign( + graph.layoutOptions, + PLACEMENT_OPTIONS, + EDGE_ROUTING_OPTIONS, + ROOT_EXPERIMENT_OVERRIDES + ); + return graph; } @@ -934,9 +1200,172 @@ function applyElkLayoutResult( ): void { const nodeById = new Map(data4Layout.nodes.map((node) => [node.id, node])); applyElkNodePositions(graph.children ?? [], layoutState, nodeById, 0, 0, 0, log); + // Between positions and edges on purpose: `boundsFor` reads the box set + // above, and `cutter2` clips an edge that ends on a group against it, so an + // edge attaching to a frame follows the frame when it moves. + evenGroupFrames(graph.children ?? [], layoutState, nodeById, graph); applyElkEdgeLayout(data4Layout, graph, layoutState, log); } +/** + * Sit each group's frame an even distance from its own contents. + * + * ELK sizes a container around everything it put inside, edges included. An + * edge that runs against the flow of the layout gets routed back around the + * outside, and when that happens inside a frame the frame grows to hold the + * lane — on one side only, since that is where the edge leaves. The result is a + * group with 76px of space on the right and 24px on the left, which reads as a + * mistake because nothing visible occupies it. + * + * The lane is real and the edge still needs it, so the fix is not to reclaim + * the space but to stop drawing the frame around it. The frame is pulled in to + * `SUBGRAPH_PADDING` from the children on the left, right and bottom, and the + * edge keeps its lane just outside — which is what an edge routed around a + * group should look like anyway. + * + * The top is left exactly as ELK set it. It carries the subgraph's title strip, + * and there is no way from here to tell how much of that padding is the label + * and how much is spare, so tightening it risks clipping the title. + * + * Runs deepest-first, so a parent measures against children that have already + * been pulled in rather than against their original boxes. + */ +export function collectDescendantIds(elkNode: any, into = new Set()): Set { + for (const child of elkNode.children ?? []) { + into.add(child.id); + collectDescendantIds(child, into); + } + return into; +} + +/** + * Absolute points of every edge ELK routed INSIDE this group — meaning both of + * its endpoints are descendants of the group. + * + * An edge with one endpoint outside is the case this whole pass exists for: its + * lane belongs to the layout around the group, not to the group, so the frame + * should not be drawn around it. An edge with both endpoints inside is the + * opposite — its lane is part of the group's interior, and a frame pulled in + * past it would leave the edge running outside a group it never leaves. + */ +function internalEdgePoints( + graph: ElkLayoutResult, + descendants: Set, + layoutState: ElkLayoutState +): P[] { + const points: P[] = []; + for (const edge of graph.edges ?? []) { + const source = edge.sources?.[0] ?? edge.start; + const target = edge.targets?.[0] ?? edge.end; + if (!descendants.has(source) || !descendants.has(target)) { + continue; + } + const offset = calcOffset(source, target, layoutState.parentLookupDb, layoutState.nodeDb); + for (const section of edge.sections ?? []) { + for (const p of [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]) { + if (p) { + points.push({ x: p.x + offset.x, y: p.y + offset.y }); + } + } + } + } + return points; +} + +export function evenGroupFrames( + elkNodes: any[], + layoutState: ElkLayoutState, + nodeById: Map, + graph: ElkLayoutResult = {} +): void { + for (const elkNode of elkNodes) { + if (!elkNode?.isGroup) { + continue; + } + const children = elkNode.children ?? []; + evenGroupFrames(children, layoutState, nodeById, graph); + + const group = layoutState.nodeDb[elkNode.id]; + const boxes = children + .map((child: { id: string }) => layoutState.nodeDb[child.id]) + .filter((child: NodeWithVertex | undefined) => child?.offset && child.width && child.height); + if (!group?.offset || boxes.length === 0) { + continue; + } + + const lane = internalEdgePoints(graph, collectDescendantIds(elkNode), layoutState); + const xs = [ + ...boxes.map((b: NodeWithVertex) => b.offset!.posX), + ...boxes.map((b: NodeWithVertex) => b.offset!.posX + b.width!), + ...lane.map((p) => p.x), + ]; + const ys = [ + ...boxes.map((b: NodeWithVertex) => b.offset!.posY), + ...boxes.map((b: NodeWithVertex) => b.offset!.posY + b.height!), + ...lane.map((p) => p.y), + ]; + + // Only ever pull a frame IN. ELK sized it to hold everything it put there, + // so growing one would mean this pass had measured something ELK had not — + // more likely a bug here than a gap there. + const origin = group.offset; + const left = Math.max(origin.posX, Math.min(...xs) - SUBGRAPH_PADDING); + const right = Math.min(origin.posX + group.width!, Math.max(...xs) + SUBGRAPH_PADDING); + const bottom = Math.min(origin.posY + group.height!, Math.max(...ys) + SUBGRAPH_PADDING); + const top = origin.posY; + + // A frame narrower than its own title would cut the title off. Both the + // drawn rect and `getEffectiveGroupWidth` have their own idea of the floor, + // so honour the larger and keep the frame centred on its contents. + const labelFloor = Math.max( + elkNode.labelData?.width ?? 0, + (elkNode.labels?.[0]?.width ?? 0) + (elkNode.padding ?? 0) + ); + let x = left; + let width = right - left; + if (width < labelFloor) { + x -= (labelFloor - width) / 2; + width = labelFloor; + // Still only ever pull IN. A title wider than the frame ELK sized is a + // frame ELK did not reserve for its own title, and widening it here would + // paper over that while breaking the one guarantee this pass makes. + const origRight = origin.posX + group.width!; + x = Math.max(origin.posX, Math.min(x, origRight - width)); + width = Math.min(width, group.width!); + } + const height = bottom - top; + if (height <= 0 || width <= 0) { + continue; + } + + // `calcOffset` resolves an edge's section against the origin of the + // container that owns it, so moving a frame would drag every edge routed + // inside it. Keep what ELK chose and let `calcOffset` read that instead. + group.elkOrigin ??= { posX: origin.posX, posY: origin.posY }; + group.offset.posX = x; + group.offset.width = width; + group.offset.height = height; + group.width = width; + group.height = height; + group.x = x + width / 2; + group.y = top + height / 2; + + const layoutNode = nodeById.get(elkNode.id); + if (layoutNode) { + layoutNode.x = group.x; + layoutNode.y = group.y; + // The clamp above, not the label floor. `width` has already honoured the + // floor wherever ELK left room for it; the only case where the label is + // still wider is the one the clamp just refused, so taking the max here + // would quietly undo it — and this is the width the frame is PAINTED at + // (`clusters.js` sizes the rect from `node.width`), so the frame would + // spill outside the bounds ELK reserved. + layoutNode.width = width; + layoutNode.height = height; + } + } +} + function applyElkNodePositions( nodeArray: any[], layoutState: ElkLayoutState, @@ -1009,6 +1438,205 @@ function applyElkNodePositions( }); } +/** + * Largest port-to-channel jog worth collapsing. + * + * ELK layered spreads an edge's port evenly along the node's side, then routes + * the edge down an inter-layer channel whose row rarely lines up with that port + * exactly. The leftover is a staircase right at the border: leave the port, run + * a few pixels, step perpendicular onto the channel, carry on. With rounded + * corners the two micro-bends sit on top of each other and read as a glitch. + * + * A step this close to the border can only be that connector — a genuine + * obstacle dodge bends much further out — so moving the terminal onto the + * channel row cannot introduce an overlap. The rest of the route is untouched. + */ +const TERMINAL_JOG_MAX = 16; + +/** + * How far from the node the step may sit and still count as the connector. + * + * Size alone does not identify a port-to-channel step: a small step a long way + * down the route is a routing decision, and collapsing it drags the port along + * for no reason. On the sample corpus every genuine connector turns 20–25 from + * the border, while the ones worth leaving alone turn at 48, 112 and 173 — one + * of which slid a port 15px into an occupied row and produced a crossing that + * was not there before. + */ +const TERMINAL_RUN_MAX = 30; + +/** + * Tolerance for deciding whether a segment counts as axis-aligned, and whether + * a step is a step at all. + * + * Deliberately much smaller than the shared `EPS` of 1, which exists for "is + * this point on a border" and is far too coarse here: ELK routinely leaves a + * sub-pixel step between the port row and the channel row, and at `EPS` those + * are not even recognised as segments. They still paint as two rounded corners + * stacked on each other, which is the artefact this pass removes — an + * `infra -> auth` edge stepped 0.858 and rendered exactly that way. + */ +const JOG_EPS = 0.01; + +/** Axis of an axis-aligned segment: `h`, `v`, or undefined when diagonal. */ +function axisOf(a: P, b: P): 'h' | 'v' | undefined { + const dx = Math.abs(b.x - a.x); + const dy = Math.abs(b.y - a.y); + if (dx > JOG_EPS && dy <= JOG_EPS) { + return 'h'; + } + if (dy > JOG_EPS && dx <= JOG_EPS) { + return 'v'; + } + return undefined; +} + +/** + * Straighten the port-to-channel staircase at either end of a clipped route, + * leaving both ports where they are. + * + * Returns the original array when nothing applies, so callers can compare by + * identity. + */ +export function straightenTerminalJogs(points: P[]): P[] { + let pts = straightenFront(points) ?? points; + const reversed = [...pts].reverse(); + const fixedEnd = straightenFront(reversed); + if (fixedEnd) { + pts = fixedEnd.reverse(); + } + return pts; +} + +/** + * Straighten the staircase at the front of `pts`, or return null when it does + * not apply. + * + * The step is removed by pulling the CHANNEL onto the port's row, never by + * pulling the port onto the channel's. Moving the port slides the attachment + * along the node border, and a node whose other edges are still at their spread + * positions then looks lopsided — the reason this was rewritten. Moving the + * channel instead keeps every port exactly where ELK placed it, at the cost of + * displacing one run, which is why the caller checks the result for crossings. + * + * The run is only moved when the point after it is not the far terminal, since + * that would move the other end's port and reintroduce the same problem there. + */ +function straightenFront(pts: P[]): P[] | null { + if (pts.length < 5) { + return null; + } + const [p0, p1, p2, p3] = pts; + const axis = axisOf(p0, p1); + if (!axis || axisOf(p2, p3) !== axis || axisOf(p1, p2) !== (axis === 'h' ? 'v' : 'h')) { + return null; + } + // The step has to be next to the node to be the port-to-channel connector. + if (Math.hypot(p1.x - p0.x, p1.y - p0.y) > TERMINAL_RUN_MAX) { + return null; + } + const jog = axis === 'h' ? Math.abs(p2.y - p1.y) : Math.abs(p2.x - p1.x); + if (jog < JOG_EPS || jog > TERMINAL_JOG_MAX) { + return null; + } + // The route has to keep travelling the same way after the step, otherwise + // this is a real turn rather than a connector. + const forward = + axis === 'h' + ? Math.sign(p1.x - p0.x) === Math.sign(p3.x - p2.x) + : Math.sign(p1.y - p0.y) === Math.sign(p3.y - p2.y); + if (!forward) { + return null; + } + // The WHOLE run has to move, not just its first segment: the channel carries + // on past p3 until the route turns, and shifting only part of it leaves a + // diagonal where the moved and unmoved halves meet. + let last = 3; + while (last + 1 < pts.length && axisOf(pts[last], pts[last + 1]) === axis) { + last++; + } + // The far terminal must not be inside the run — moving it would drag the + // other end's port, which is the thing this avoids. + if (last === pts.length - 1) { + return null; + } + + // No border check is needed: the port is untouched, so it stays exactly where + // ELK put it, and the run moves onto that same row — which the first segment + // already occupied on its way out of the node. + const moved = [...pts]; + for (let i = 2; i <= last; i++) { + moved[i] = axis === 'h' ? { x: pts[i].x, y: p0.y } : { x: p0.x, y: pts[i].y }; + } + // p1 and p2 are now collinear with p0 and the rest of the run. + moved.splice(1, 2); + return moved; +} + +/** Do two axis-aligned segments cross at a point interior to both? */ +function segmentsCrossStrict(a1: P, a2: P, b1: P, b2: P): boolean { + const side = (o: P, p: P, q: P) => (p.x - o.x) * (q.y - o.y) - (p.y - o.y) * (q.x - o.x); + const d1 = side(b1, b2, a1); + const d2 = side(b1, b2, a2); + const d3 = side(a1, a2, b1); + const d4 = side(a1, a2, b2); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); +} + +/** How many times one polyline crosses another. */ +function crossingCount(a: P[], b: P[]): number { + let n = 0; + for (let i = 0; i < a.length - 1; i++) { + for (let j = 0; j < b.length - 1; j++) { + if (segmentsCrossStrict(a[i], a[i + 1], b[j], b[j + 1])) { + n++; + } + } + } + return n; +} + +/** + * Straighten the port-to-channel step on every edge that has one, but only + * where doing so does not buy a crossing. + * + * Runs once over the finished layout rather than per edge, because the decision + * needs the other edges: the step is removed by displacing one of this edge's + * runs onto the port's row, and that run can land in a lane something else + * already occupies. Trading a barely-visible step for a new crossing is a bad + * deal, so an edge that would cause one is left exactly as ELK routed it. + */ +function straightenEdgeTerminals(edges: Edge[]): void { + const routes = edges.map((edge) => (edge as { points?: P[] }).points ?? []); + + for (const [index, edge] of edges.entries()) { + const original = routes[index]; + if (original.length < 5) { + continue; + } + const candidate = straightenTerminalJogs(original); + if (candidate === original) { + continue; + } + + let before = 0; + let after = 0; + for (const [other, route] of routes.entries()) { + if (other === index || route.length < 2) { + continue; + } + before += crossingCount(original, route); + after += crossingCount(candidate, route); + } + if (after > before) { + continue; + } + + (edge as { points?: P[] }).points = candidate; + routes[index] = candidate; + } +} + function applyElkEdgeLayout( data4Layout: LayoutData, graph: ElkLayoutResult, @@ -1016,6 +1644,8 @@ function applyElkEdgeLayout( log: ElkLayoutContext['log'] ): void { const edgeById = new Map(data4Layout.edges.map((edge) => [edge.id, edge])); + // Opt-out rather than opt-in: the step this removes is never intentional. + const straightenEdges = data4Layout.config.elk?.straightenEdges !== false; graph.edges?.forEach((edge) => { const layoutEdge = edgeById.get(edge.id); @@ -1088,8 +1718,9 @@ function applyElkEdgeLayout( points.push({ x: endNode.x, y: endNode.y }); } + const clipped = sanitizeElkEdgePoints(points, startNode, endNode, log); layoutEdge.points = ensureEndMarkerSegmentLength( - sanitizeElkEdgePoints(points, startNode, endNode, log), + clipped, boundsFor(endNode), getEndMarkerPathOffset(layoutEdge), log @@ -1102,6 +1733,10 @@ function applyElkEdgeLayout( layoutEdge.y = label.y + offset.y + label.height / 2; } }); + + if (straightenEdges) { + straightenEdgeTerminals(data4Layout.edges); + } } function createEdgePointsFromSection(section: any, offset: { x: number; y: number }): P[] { @@ -1131,7 +1766,11 @@ function calcOffset( return { x: 0, y: 0 }; } - const ancestorOffset = nodeDb[ancestor]?.offset; + // `elkOrigin` when present: `evenGroupFrames` may have moved the frame, but a + // section's coordinates are relative to where ELK put the container, not to + // where the frame is now drawn. + const node = nodeDb[ancestor]; + const ancestorOffset = node?.elkOrigin ?? node?.offset; return { x: ancestorOffset?.posX ?? 0, y: ancestorOffset?.posY ?? 0, @@ -1504,6 +2143,33 @@ function applyEndIntersectionIfNeeded( } } +/** + * Attachment point for the terminal at `portIndex`, on the axis the edge + * departs along. + * + * `step` is +1 at the start of the polyline and -1 at the end, i.e. the + * direction that walks AWAY from the node, which is what gives the departure + * direction. Groups are excluded: their frame already is their outline, and the + * caller has its own on-border handling for them. + */ +function attachAlongDepartureAxis( + node: NodeWithVertex, + bounds: RectLike, + points: P[], + portIndex: number, + step: 1 | -1 +): P | null { + if (node?.isGroup) { + return null; + } + const port = points[portIndex]; + const next = points[portIndex + step]; + if (!port || !next) { + return null; + } + return outlineAttachPoint(node, bounds, port, next); +} + function cutter2( startNode: NodeWithVertex, endNode: NodeWithVertex, @@ -1534,12 +2200,12 @@ function cutter2( if (firstOutsideStartIndex !== -1) { const outsidePointForStart = points[firstOutsideStartIndex]; - const startIntersection = computeNodeIntersection( - startNode, - startBounds, - outsidePointForStart, - startCenter - ); + const startIntersection = + // Prefer an attachment on the edge's own departure axis; see + // `outlineAttachPoint`. Falls back to the centre-ray intersection, which + // is all a non-axis-aligned or shapeless endpoint can offer. + attachAlongDepartureAxis(startNode, startBounds, points, firstOutsideStartIndex, 1) ?? + computeNodeIntersection(startNode, startBounds, outsidePointForStart, startCenter); log.debug('UIO cutter2: start intersection', startIntersection); replaceEndpoint(points, 'start', startIntersection); } @@ -1561,12 +2227,9 @@ function cutter2( } if (outsidePointForEnd) { - const endIntersection = computeNodeIntersection( - endNode, - endBounds, - outsidePointForEnd, - endCenter - ); + const endIntersection = + attachAlongDepartureAxis(endNode, endBounds, points, outsideIndexForEnd, -1) ?? + computeNodeIntersection(endNode, endBounds, outsidePointForEnd, endCenter); log.debug('UIO cutter2: end intersection', { endIntersection, outsideIndexForEnd }); replaceEndpoint(points, 'end', endIntersection); } diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index 8569fe06042..0aa319e7550 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -36,7 +36,7 @@ "clean": "rimraf dist", "dev": "pnpm -w dev", "docs:code": "typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup", - "docs:build": "rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts", + "docs:build": "pnpm docs:code && pnpm docs:spellcheck && rimraf ../../docs && tsx scripts/docs.cli.mts", "docs:verify": "pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify", "docs:pre:vitepress": "pnpm --filter ./src/docs prefetch && rimraf src/vitepress && pnpm docs:code && tsx scripts/docs.cli.mts --vitepress && pnpm --filter ./src/vitepress install --no-frozen-lockfile --ignore-scripts", "docs:build:vitepress": "pnpm docs:pre:vitepress && (cd src/vitepress && pnpm run build) && cpy --flat src/docs/landing/ ./src/vitepress/.vitepress/dist/landing", diff --git a/packages/mermaid/scripts/docsBuildOrder.spec.ts b/packages/mermaid/scripts/docsBuildOrder.spec.ts new file mode 100644 index 00000000000..7277c8142e2 --- /dev/null +++ b/packages/mermaid/scripts/docsBuildOrder.spec.ts @@ -0,0 +1,51 @@ +/** + * `docs:build` regenerates the committed `docs/` directory, and it has to delete the old + * contents first so that pages whose source was removed do not linger. + * + * The order of those steps matters. `rimraf ../../docs` used to run *first*, before + * `docs:code` (typedoc) and `docs:spellcheck` — so any failure in either left the whole + * committed `docs/` tree deleted and never regenerated. A contributor whose typedoc run + * errored, or who added one unrecognised word to a doc, was handed ~150 staged deletions + * with no obvious cause. + * + * This is a property of a package.json script rather than of any module, so there is + * nothing else to hang a test on — but it is exactly the kind of thing that gets + * reintroduced by someone tidying the script into what looks like a more natural order + * (clean, then build). + */ +import { describe, expect, it } from 'vitest'; +import packageJson from '../package.json' with { type: 'json' }; + +const stepsOf = (script: string) => script.split('&&').map((step) => step.trim()); + +const indexOfStep = (steps: string[], needle: string) => + steps.findIndex((step) => step.includes(needle)); + +describe('docs:build step order', () => { + const script: string = packageJson.scripts['docs:build']; + const steps = stepsOf(script); + + it('still deletes the docs directory, so removed pages do not linger', () => { + expect(indexOfStep(steps, 'rimraf ../../docs')).toBeGreaterThanOrEqual(0); + }); + + it('regenerates the docs directory in the same run', () => { + expect(indexOfStep(steps, 'docs.cli.mts')).toBeGreaterThanOrEqual(0); + }); + + it.each(['docs:code', 'docs:spellcheck'])( + 'runs %s before deleting the docs directory', + (fallibleStep) => { + const deleteAt = indexOfStep(steps, 'rimraf ../../docs'); + const stepAt = indexOfStep(steps, fallibleStep); + expect(stepAt).toBeGreaterThanOrEqual(0); + expect(stepAt).toBeLessThan(deleteAt); + } + ); + + it('deletes the docs directory immediately before regenerating it', () => { + const deleteAt = indexOfStep(steps, 'rimraf ../../docs'); + const generateAt = indexOfStep(steps, 'docs.cli.mts'); + expect(generateAt).toBe(deleteAt + 1); + }); +}); diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 6141e7db15c..3afc433e008 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -125,6 +125,92 @@ export interface MermaidConfig { * */ nodePlacementAlignment?: 'NONE' | 'LEFTUP' | 'LEFTDOWN' | 'RIGHTUP' | 'RIGHTDOWN' | 'BALANCED'; + /** + * Named combination of the three options that decide where nodes end up: + * layering strategy, node placement strategy and cycle breaking strategy. + * They belong to different phases of the layout, so a preset is simply a + * named triple rather than a mode with behaviour of its own. + * + * `default` — network simplex layering and placement with depth-first + * cycle breaking at the top level, and Brandes-Koepf placement inside + * subgraphs. Depth-first breaking gives shorter back edges on graphs + * that loop. + * + * `legacy` — what shipped before presets existed: Brandes-Koepf placement, + * which straightens long edges at the cost of that alignment, with ELK's + * own greedy cycle breaking. Reproduces the rendering of earlier + * versions rather than the defaults their schema advertised. + * + * `modelOrder` — as `default`, but breaks cycles by greedy model order, + * which disturbs declaration order least at the cost of longer back + * edges. This is the combination `default` named previously. + * + * `depthFirst` — a name for what `default` already is, for diagrams that + * would rather say depth-first than rely on the default. + * + * Setting `layeringStrategy`, `nodePlacementStrategy` or + * `cycleBreakingStrategy` explicitly overrides the preset for that one + * option; the rest of the preset still applies. + * + */ + preset?: 'default' | 'legacy' | 'modelOrder' | 'depthFirst'; + /** + * Straightens an edge that leaves or enters a node with a tiny step. + * + * ELK spreads an edge's port evenly along a node's side but routes the + * edge down a channel whose row rarely lines up with that port exactly, + * leaving a staircase of a few pixels right at the border. With rounded + * corners the two micro-bends land on top of each other and read as a + * kink. Enabling this moves the channel onto the port's row and drops + * the step, so the edge draws as one straight line and both ports stay + * exactly where the layout put them. + * + * Only the step next to a node is touched, and only when the edge + * continues the same way afterwards, so a real turn is never collapsed. + * An edge is left alone entirely when moving its run would drag the far + * port, or would introduce a crossing. + * + */ + straightenEdges?: boolean; + /** + * Renders edge crossings as small arcs ("hops") or visible gaps, so that + * it is clear which line passes over which where two edges meet. + * + * The edge that gives way loses its corner rounding for the segment + * carrying the hop, which is the trade for a readable crossing. Curved + * edges are skipped rather than rewritten, to avoid corrupting their + * geometry. Set to `false` to draw plain crossings. + * + */ + lineHops?: boolean | ('arc' | 'gap'); + /** + * Elk specific option deciding which layer each node is assigned to — the + * column in a left-to-right diagram, the row in a top-down one. This is + * the coarsest of the three placement decisions, so changing it moves + * nodes further than anything else short of altering spacing. + * + * NETWORK_SIMPLEX aims for the fewest long edges. LONGEST_PATH pushes + * every node as late as it can go. COFFMAN_GRAHAM bounds how many nodes + * share a layer, giving a more even, block-like shape on wide graphs. + * MIN_WIDTH and STRETCH_WIDTH trade edge length for a narrower or wider + * drawing. INTERACTIVE honours positions already on the nodes. + * + */ + layeringStrategy?: + | 'NETWORK_SIMPLEX' + | 'LONGEST_PATH' + | 'LONGEST_PATH_SOURCE' + | 'COFFMAN_GRAHAM' + | 'MIN_WIDTH' + | 'STRETCH_WIDTH' + | 'INTERACTIVE'; + /** + * Elk specific option capping how many nodes COFFMAN_GRAHAM will put in + * one layer. Ignored by every other layering strategy. Lower values give + * a taller, narrower drawing. + * + */ + layeringLayerBound?: number; /** * This strategy decides how to find cycles in the graph and deciding which edges need adjustment to break loops. * diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 208fee3534f..c52397f218b 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -23,8 +23,19 @@ const config: RequiredDeep = { elk: { // mergeEdges is needed here to be considered mergeEdges: false, - nodePlacementStrategy: 'BRANDES_KOEPF', + straightenEdges: true, + lineHops: true, + preset: 'default', + // Left undefined so `??` can tell "the user chose this" from "nobody did", + // which is what lets `elk.preset` supply a value while an explicit setting + // still wins. Listed rather than omitted so `configKeys` still finds them. + nodePlacementStrategy: undefined, + layeringStrategy: undefined, + cycleBreakingStrategy: undefined, + layeringLayerBound: 4, + // Brandes-Koepf specific; inert unless nodePlacementStrategy is set back to it. nodePlacementAlignment: 'NONE', + forceNodeModelOrder: false, considerModelOrder: 'NODES_AND_EDGES', keepEntryNodeOnTop: false, diff --git a/packages/mermaid/src/diagrams/er/styles.ts b/packages/mermaid/src/diagrams/er/styles.ts index c9edd0647b2..546cd20dc9c 100644 --- a/packages/mermaid/src/diagrams/er/styles.ts +++ b/packages/mermaid/src/diagrams/er/styles.ts @@ -16,23 +16,33 @@ const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); const genColor: DiagramStylesProvider = (options) => { const { theme, look, bkgColorArray, borderColorArray } = options; - if (!COLOR_THEMES.has(theme)) { + // Bail on an empty border palette as well as a non-colour theme. Without the second + // check `i % borderColorArray.length` is `i % 0` -> NaN, and `[][NaN]` is `undefined`, + // so every slot would emit `stroke: undefined` -- the exact symptom this guard exists to + // prevent. `requirement/styles.js` already gated on the palette; this brings ER into + // line. Reachable through a `themeVariables` override, not just in theory. + if (!COLOR_THEMES.has(theme) || !borderColorArray?.length) { return ''; } const hasBkgColors = bkgColorArray?.length > 0; let sections = ''; for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + // Wrap at the palette length rather than indexing raw. The loop runs to + // THEME_COLOR_LIMIT, so a palette with fewer entries than that emits + // `stroke: undefined` for the overflow slots. + const borderColor = borderColorArray[i % borderColorArray.length]; + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; sections += ` [data-look="${look}"][data-color-id="color-${i}"].node path { - stroke: ${borderColorArray[i]}; - ${hasBkgColors ? `fill: ${bkgColorArray[i]};` : ''} + stroke: ${borderColor}; + ${fill} } [data-look="${look}"][data-color-id="color-${i}"].node rect { - stroke: ${borderColorArray[i]}; - ${hasBkgColors ? `fill: ${bkgColorArray[i]};` : ''} + stroke: ${borderColor}; + ${fill} } `; } diff --git a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts new file mode 100644 index 00000000000..0e54ac66ecc --- /dev/null +++ b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts @@ -0,0 +1,193 @@ +/** + * The ER and requirement stylesheets generate one CSS rule per palette slot, looping to + * `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Three things went + * wrong with that, and none produces an error anywhere — they emit CSS the browser quietly + * discards, so the only symptom is a shape rendering unstyled. No screenshot test can + * catch it: a diagram renders identically with or without a discarded declaration. + * + * 1. Indexing raw means a palette shorter than `THEME_COLOR_LIMIT` yields + * `stroke: undefined` for the overflow slots. + * 2. Wrapping with `i % length` fixes that but reintroduces it for an *empty* palette: + * `i % 0` is `NaN` and `[][NaN]` is `undefined`. Both files bail early instead. + * 3. `requirement` emitted `fill: ;` — an empty value, invalid CSS — whenever there was + * no background palette. `redux-dark-color` is the live case: it ships a border + * palette and no background palette, colouring outlines only. + * + * These assertions are about the shape of the generated CSS rather than the colours, so + * they keep holding when the palettes are retuned. + * + * Deliberately a sibling of the diagram folders rather than inside `common/`: it imports + * two diagram stylesheets, and `common/` is imported by every diagram type, so a + * cross-diagram spec in there would imply a dependency that does not exist. + */ +import { describe, expect, it, afterEach } from 'vitest'; +import * as configApi from '../config.js'; +import type { MermaidConfig } from '../config.type.js'; +import themes from '../themes/index.js'; +import erStyles from './er/styles.js'; +import requirementStyles from './requirement/styles.js'; +import timelineStyles from './timeline/styles.js'; + +const STYLESHEETS = { + er: erStyles, + requirement: requirementStyles, + timeline: timelineStyles, +} as const; + +type Stylesheet = keyof typeof STYLESHEETS; + +const ALL_STYLESHEETS = Object.keys(STYLESHEETS) as Stylesheet[]; + +/** + * Which of them emit `[data-color-id]` slot rules. `timeline` is palette-aware but + * colours `.section-N` classes directly, so the slot-shaped assertions do not apply to + * it — only the invalid-CSS ones do. + */ +const SLOT_STYLESHEETS = ['er', 'requirement'] as const satisfies readonly Stylesheet[]; + +/** Matches `theme-base.js`; the palette rules are emitted one per slot up to this. */ +const THEME_COLOR_LIMIT = 12; + +const COLOUR_THEMES = [ + 'redux-color', + 'redux-dark-color', +] as const satisfies MermaidConfig['theme'][]; + +/** The subset of theme variables these stylesheets read. */ +interface PaletteOptions { + theme: MermaidConfig['theme']; + look: MermaidConfig['look']; + THEME_COLOR_LIMIT: number; + borderColorArray?: string[]; + bkgColorArray?: string[]; + [key: string]: unknown; +} + +/** + * Drives both channels: `er/styles.ts` reads the theme and palette off its options + * argument, while `requirement/styles.js` reads them from `getConfig()`. + */ +const render = ( + name: keyof typeof STYLESHEETS, + theme: MermaidConfig['theme'], + overrides: Partial = {} +): string => { + const themeVariables = themes[theme as keyof typeof themes].getThemeVariables({}); + const options: PaletteOptions = { + ...(themeVariables as unknown as Record), + theme, + look: 'classic', + THEME_COLOR_LIMIT, + ...overrides, + }; + configApi.reset(); + configApi.setSiteConfig({ theme, look: 'classic', themeVariables: options }); + return STYLESHEETS[name](options); +}; + +/** The declaration bodies of the palette rules, ignoring the rest of the stylesheet. */ +const paletteBlocks = (css: string): string[] => + [...css.matchAll(/\[data-color-id="color-\d+"][^{]*{([^}]*)}/g)].map((m) => m[1]); + +/** + * The declaration bodies of timeline's `.section-N` rules. Anchored on the ` rect,` that + * opens each selector list, so it skips `.section-root` and the `[data-look="neo"]` + * gradient variants. + */ +const sectionBlocks = (css: string): string[] => + [...css.matchAll(/\.section--?\d+ rect,[^{]*{([^}]*)}/g)].map((m) => m[1]); + +const strokesIn = (blocks: string[]): string[] => + blocks.flatMap((block) => [...block.matchAll(/stroke:\s*([^;]+);/g)].map((m) => m[1].trim())); + +afterEach(() => { + configApi.reset(); +}); + +describe.each(ALL_STYLESHEETS)('%s stylesheet', (name) => { + it.each(COLOUR_THEMES)('emits no undefined values for %s', (theme) => { + expect(render(name, theme)).not.toContain('undefined'); + }); + + it.each(COLOUR_THEMES)('emits no empty declarations for %s', (theme) => { + // `fill: ;` and friends — a property with no value at all. + expect(render(name, theme)).not.toMatch(/[\w-]+:\s*;/); + }); + + it('emits no undefined values for a palette shorter than THEME_COLOR_LIMIT', () => { + const css = render(name, 'redux-color', { + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }); + expect(css).not.toContain('undefined'); + }); + + it('emits no undefined values for an empty border palette', () => { + // `i % 0` is NaN, so wrapping the index is not enough on its own — the guard has to + // bail before the loop. + expect(render(name, 'redux-color', { borderColorArray: [] })).not.toContain('undefined'); + }); +}); + +describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => { + it('resolves every slot from a palette shorter than THEME_COLOR_LIMIT', () => { + const css = render(name, 'redux-color', { + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }); + // Every slot still gets a rule, and every rule names one of the two colours. Scoped to + // the palette blocks — the rest of the stylesheet has its own `stroke:` declarations. + // Counted exactly: a `greaterThan` bound would still pass if only a couple of slots + // were emitted, which is the regression this is here to catch. Each slot emits a + // `path` rule and a `rect` rule, hence twice the limit. + const blocks = paletteBlocks(css); + const strokes = strokesIn(blocks); + expect(blocks).toHaveLength(THEME_COLOR_LIMIT * 2); + expect(strokes).toHaveLength(THEME_COLOR_LIMIT * 2); + expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); + }); + + it('emits no slot rules at all for an empty border palette', () => { + // Emitting nothing is the correct outcome: there is no palette to render. + expect(paletteBlocks(render(name, 'redux-color', { borderColorArray: [] }))).toEqual([]); + }); + + it('omits the fill declaration when there is no background palette', () => { + const css = render(name, 'redux-color', { bkgColorArray: [] }); + expect(css).not.toMatch(/fill:\s*;/); + // Scoped to a palette rule: the base stylesheet carries its own `stroke:` declarations, + // so an unscoped check would pass even if genColor returned nothing. + expect(paletteBlocks(css).length).toBeGreaterThan(0); + expect(paletteBlocks(css).every((block) => block.includes('stroke:'))).toBe(true); + }); +}); + +describe('timeline section rules', () => { + it('wraps the palette across every section rather than falling back', () => { + const borderColorArray = ['#ff0000', '#00ff00']; + const css = render('timeline', 'redux-color', { borderColorArray }); + const strokes = strokesIn(sectionBlocks(css)); + + // The assertion has to be the wrapped *sequence*, not just the absence of `undefined`. + // `slot ?? options.nodeBorder` means dropping the `% length` wrap sends the overflow + // slots to the classic fallback colour instead of leaving them undefined, so a + // `not.toContain('undefined')` check passes either way and catches nothing. What + // actually regresses is sections 3..12 silently losing their palette colours. + expect(strokes).toHaveLength(THEME_COLOR_LIMIT); + expect(strokes).toEqual( + Array.from( + { length: THEME_COLOR_LIMIT }, + (_, i) => borderColorArray[i % borderColorArray.length] + ) + ); + }); + + it('falls back to the classic colours for an empty border palette', () => { + const css = render('timeline', 'redux-color', { borderColorArray: [] }); + const strokes = strokesIn(sectionBlocks(css)); + expect(strokes).toHaveLength(THEME_COLOR_LIMIT); + expect(new Set(strokes)).not.toContain(undefined); + // No palette to cycle, so every section takes the classic border colour. + expect(new Set(strokes).size).toBe(1); + }); +}); diff --git a/packages/mermaid/src/diagrams/requirement/styles.js b/packages/mermaid/src/diagrams/requirement/styles.js index 60154211faa..d5328336635 100644 --- a/packages/mermaid/src/diagrams/requirement/styles.js +++ b/packages/mermaid/src/diagrams/requirement/styles.js @@ -10,17 +10,27 @@ const genColor = (options) => { } let sections = ''; + const hasBkgColors = bkgColorArray?.length > 0; + for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + // Omit the declaration when there is no fill palette, rather than emitting `fill: ;` + // -- an empty value is invalid CSS. `redux-dark-color` is the live case: it ships a + // border palette and no background palette, colouring outlines only. + // + // Wrap at the palette length for the same reason as `er/styles.ts`: the loop runs to + // THEME_COLOR_LIMIT, so a shorter palette would emit `stroke: undefined`. + const borderColor = borderColorArray[i % borderColorArray.length]; + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; sections += ` [data-look="${look}"][data-color-id="color-${i}"].node path { - stroke: ${borderColorArray[i]}; - fill: ${bkgColorArray?.length ? bkgColorArray[i] : ''}; + stroke: ${borderColor}; + ${fill} } [data-look="${look}"][data-color-id="color-${i}"].node rect { - stroke: ${borderColorArray[i]}; - fill: ${bkgColorArray?.length ? bkgColorArray[i] : ''}; + stroke: ${borderColor}; + ${fill} } `; } diff --git a/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts new file mode 100644 index 00000000000..26d8e03db89 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/palettePicking.spec.ts @@ -0,0 +1,129 @@ +/** + * Sequence colours each actor from the theme palette. Every call site used to index + * `bkgColorArray` by `borderColorArray.length` — one array by the other's length. + * + * That is invisible while both palettes ship twelve entries, which is why it survived: it + * is wrong only when they differ. A background palette shorter than the border palette + * leaves the overflow actors resolving to `undefined`, and because + * `selection.style(name, undefined)` takes d3's *remove* path, the inline fill silently + * disappears for some actors and not others. + * + * `redux-dark-color` depends on that remove path: it ships a border palette and an empty + * background palette so actors are outlined but not filled. So the fix has to keep + * returning `undefined` for an absent palette rather than substituting a colour — these + * assertions pin both halves. + */ +import { describe, expect, it } from 'vitest'; +import themes from '../../themes/index.js'; +import { paletteColor } from './svgDraw.js'; +// Vite's `?raw` gives the module's own source, so the guard below reads the real file +// without depending on the working directory. Declared in `src/type.d.ts`. +import svgDrawSource from './svgDraw.js?raw'; + +describe('paletteColor', () => { + const palette = ['#a', '#b', '#c']; + + it('cycles within the palette it was given', () => { + expect([0, 1, 2, 3, 4, 5].map((i) => paletteColor(palette, i))).toEqual([ + '#a', + '#b', + '#c', + '#a', + '#b', + '#c', + ]); + }); + + it('returns undefined for an empty palette rather than a substitute colour', () => { + // `undefined` is what makes d3 remove the inline style and defer to the stylesheet. + expect(paletteColor([], 0)).toBeUndefined(); + expect(paletteColor(undefined, 3)).toBeUndefined(); + }); + + it('never runs off the end of a short palette', () => { + // The old code asked for index `i % 12` from a 3-entry array. + const asked = Array.from({ length: 12 }, (_, i) => paletteColor(palette, i)); + expect(asked).not.toContain(undefined); + expect(new Set(asked)).toEqual(new Set(palette)); + }); + + it('is independent of any other palette length', () => { + // The bug in one line: a 2-entry background palette indexed by a 12-entry border + // palette's length loses every actor past the second. + const short = ['#x', '#y']; + const oldWay = Array.from({ length: 12 }, (_, i) => short[i % 12]); + const newWay = Array.from({ length: 12 }, (_, i) => paletteColor(short, i)); + expect(oldWay.filter((c) => c === undefined)).toHaveLength(10); + expect(newWay.filter((c) => c === undefined)).toHaveLength(0); + }); +}); + +/** + * The shipped palettes must render exactly as before — this is a latent-bug fix, not a + * visual change, and `patch` is only honest if that holds. + */ +describe('shipped colour themes are unaffected', () => { + it.each(['redux-color', 'redux-dark-color'] as const)('%s', (name) => { + const variables = themes[name].getThemeVariables({}) as unknown as { + borderColorArray: string[]; + bkgColorArray: string[]; + }; + const { borderColorArray, bkgColorArray } = variables; + + for (let i = 0; i < 24; i++) { + // What the old expression produced, verbatim. + const oldStroke = borderColorArray[i % borderColorArray.length]; + const oldFill = bkgColorArray[i % borderColorArray.length]; + expect(paletteColor(borderColorArray, i)).toBe(oldStroke); + expect(paletteColor(bkgColorArray, i)).toBe(oldFill); + } + }); + + it('redux-dark-color still yields no fill, so the stylesheet keeps deciding', () => { + const { bkgColorArray } = themes['redux-dark-color'].getThemeVariables({}) as unknown as { + bkgColorArray: string[]; + }; + expect(bkgColorArray).toHaveLength(0); + expect(paletteColor(bkgColorArray, 0)).toBeUndefined(); + }); +}); + +/** + * The helper assertions above cannot see a call site that goes back to indexing one + * palette by the other's length — and that is the failure mode with history here: the + * expression was copy-pasted across eleven actor drawers before anyone noticed. A new actor + * type copied from an existing one is the obvious way for it to return, so guard the shape + * of the source rather than only the helper's behaviour. + */ +describe('no call site indexes one palette by another', () => { + const source: string = svgDrawSource; + + it('has no raw palette indexing left', () => { + const raw = [...source.matchAll(/(\w*ColorArray)\[[^\]]*?(\w*ColorArray)\.length]/g)].map( + (m) => m[0] + ); + expect(raw).toEqual([]); + }); + + it('routes every actor colour through paletteColor, each to its own property', () => { + // Capture the property as well as the palette. Collecting only the palette argument + // would be blind to a swapped pair -- `stroke` fed from `bkgColorArray` and `fill` from + // `borderColorArray` -- which is exactly what a copy-paste between two adjacent + // `.style()` lines produces, and which no unit test here would otherwise notice. + const pairs = [ + ...source.matchAll(/\.style\(\s*'(stroke|fill)',\s*paletteColor\((\w+),\s*actorCount\)/g), + ].map((m) => `${m[1]}<-${m[2]}`); + const strokes = pairs.filter((pair) => pair === 'stroke<-borderColorArray'); + const fills = pairs.filter((pair) => pair === 'fill<-bkgColorArray'); + + // Each pairing must be the right way round... + expect(new Set(pairs)).toEqual(new Set(['stroke<-borderColorArray', 'fill<-bkgColorArray'])); + // ...and the two must stay balanced, because every drawer paints both. A set plus a + // total count cannot see a half-converted file -- 21 strokes and a single fill would + // satisfy both. Counting each side catches that. + expect(strokes).toHaveLength(fills.length); + // A floor rather than an exact 11, so adding a twelfth actor drawer does not fail a + // correct change; it still catches drawers being dropped or left unconverted. + expect(strokes.length).toBeGreaterThanOrEqual(11); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/svgDraw.js b/packages/mermaid/src/diagrams/sequence/svgDraw.js index 0876acd9e22..2ae3fa9b566 100644 --- a/packages/mermaid/src/diagrams/sequence/svgDraw.js +++ b/packages/mermaid/src/diagrams/sequence/svgDraw.js @@ -16,6 +16,28 @@ const ACTOR_MAN_FIGURE_CLASS = 'actor-man'; /** Exact set of themes that use color arrays for actor styling */ const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); + +/** + * Pick a colour for the nth actor from a palette, cycling within that palette's own + * length. + * + * Every call site used to index `bkgColorArray` by `borderColorArray.length` -- one array + * by the other's length. That is invisible while both ship twelve entries, and wrong the + * moment they differ: a shorter background palette leaves the overflow actors with + * `undefined`, so d3 strips the inline fill for some actors and not others. + * + * `undefined` for an absent or empty palette is deliberate rather than a fallback colour. + * `selection.style(name, undefined)` takes d3's remove path, which lets the stylesheet + * decide -- and that is exactly what `redux-dark-color` relies on, shipping a border + * palette and an empty background palette so that actors are outlined but not filled. + * + * `drawActivation` is the one caller that does add `?? mainBkg`, and deliberately so: an + * activation rect spans the lifeline it sits on, so it needs an opaque fill or the line + * shows through it. That is a question of opacity rather than of palette, which is why it + * belongs at that call site and not in here. + */ +export const paletteColor = (palette, index) => + palette?.length ? palette[index % palette.length] : undefined; export const drawRect = function (elem, rectData) { const rectElement = svgDrawCommon.drawRect(elem, rectData); // Call getConfig() here (not at module level) so multi-diagram pages get fresh config @@ -405,8 +427,8 @@ const drawActorTypeParticipant = function (elem, actor, conf, isFooter, actorInd const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - rectElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - rectElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + rectElem.style('stroke', paletteColor(borderColorArray, actorCount)); + rectElem.style('fill', paletteColor(bkgColorArray, actorCount)); } if (look === 'neo') { rectElem.attr('filter', 'url(#drop-shadow)'); @@ -536,10 +558,10 @@ const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorInd const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - rectElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - rectElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); - stackedRect.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - stackedRect.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + rectElem.style('stroke', paletteColor(borderColorArray, actorCount)); + rectElem.style('fill', paletteColor(bkgColorArray, actorCount)); + stackedRect.style('stroke', paletteColor(borderColorArray, actorCount)); + stackedRect.style('fill', paletteColor(bkgColorArray, actorCount)); } if (actor.properties?.icon) { @@ -671,10 +693,10 @@ const drawActorTypeQueue = function (elem, actor, conf, isFooter, actorIndexMap) const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - cylinderGroup.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - cylinderGroup.style('fill', bkgColorArray[actorCount % borderColorArray.length]); - cylinderArc.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - cylinderArc.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + cylinderGroup.style('stroke', paletteColor(borderColorArray, actorCount)); + cylinderGroup.style('fill', paletteColor(bkgColorArray, actorCount)); + cylinderArc.style('stroke', paletteColor(borderColorArray, actorCount)); + cylinderArc.style('fill', paletteColor(bkgColorArray, actorCount)); } if (actor.properties?.icon) { @@ -794,8 +816,8 @@ const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, a const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } else { actElem.style('stroke', actorBorder); actElem.style('fill', actorBkg); @@ -876,8 +898,8 @@ const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } const bounds = actElem.node().getBBox(); @@ -1013,8 +1035,8 @@ const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexM } const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - cylinderGroup.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - cylinderGroup.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + cylinderGroup.style('stroke', paletteColor(borderColorArray, actorCount)); + cylinderGroup.style('fill', paletteColor(bkgColorArray, actorCount)); } else { cylinderGroup.style('stroke', actorBorder); } @@ -1121,8 +1143,8 @@ const drawActorTypeBoundary = function (elem, actor, conf, isFooter, actorIndexM const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } else { actElem.style('stroke', actorBorder); } @@ -1250,8 +1272,8 @@ const drawActorTypeActor = function (elem, actor, conf, isFooter, actorIndexMap) const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { - actElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - actElem.style('fill', bkgColorArray[actorCount % borderColorArray.length]); + actElem.style('stroke', paletteColor(borderColorArray, actorCount)); + actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } else { actElem.style('stroke', actorBorder); } @@ -1372,8 +1394,8 @@ export const drawActivation = function ( ); const actorCount = resolvedActorIndexMap.get(actor) ?? 0; if (COLOR_THEMES.has(theme)) { - rectElem.style('stroke', borderColorArray[actorCount % borderColorArray.length]); - rectElem.style('fill', bkgColorArray[actorCount % borderColorArray.length] ?? mainBkg); + rectElem.style('stroke', paletteColor(borderColorArray, actorCount)); + rectElem.style('fill', paletteColor(bkgColorArray, actorCount) ?? mainBkg); } }; diff --git a/packages/mermaid/src/diagrams/timeline/styles.js b/packages/mermaid/src/diagrams/timeline/styles.js index be5a36c90bb..10b075b7cc1 100644 --- a/packages/mermaid/src/diagrams/timeline/styles.js +++ b/packages/mermaid/src/diagrams/timeline/styles.js @@ -6,7 +6,11 @@ const genReduxSections = (options) => { //Required to read the active theme at render time, // since options alone does not expose the theme name needed to switch between redux and classic section generators. const isDarkTheme = theme?.includes('dark'); - const isColorTheme = theme?.includes('color'); + // Gate on the palette actually being present, not just on the theme name. `theme` comes + // from global config while `options` is passed in, so the two can disagree -- and with an + // empty palette `borderColorArray[i]` is `undefined`, emitting `stroke: undefined`. Same + // guard as `er/styles.ts` and `requirement/styles.js`. + const isColorTheme = theme?.includes('color') && options.borderColorArray?.length > 0; const rawSvgId = options.svgId?.replace(/^#/, '') ?? ''; const scopedDropShadow = rawSvgId ? `url(#${rawSvgId}-drop-shadow)` @@ -16,8 +20,13 @@ const genReduxSections = (options) => { for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { const sw = `${17 - 3 * i}`; - const color = isColorTheme ? options.borderColorArray[i] : options.mainBkg; - const stroke = isColorTheme ? options.borderColorArray[i] : options.nodeBorder; + // Wrap at the palette length rather than indexing raw: the loop runs to + // THEME_COLOR_LIMIT, so a shorter palette would leave the overflow slots undefined. + const slot = isColorTheme + ? options.borderColorArray[i % options.borderColorArray.length] + : undefined; + const color = slot ?? options.mainBkg; + const stroke = slot ?? options.nodeBorder; sections += ` .section-${i - 1} rect, diff --git a/packages/mermaid/src/mermaid.ts b/packages/mermaid/src/mermaid.ts index 209a5e06412..20024e4aa64 100644 --- a/packages/mermaid/src/mermaid.ts +++ b/packages/mermaid/src/mermaid.ts @@ -54,6 +54,11 @@ export type { CommonLayoutRenderContext, CommonLayoutRendererDefinition, } from './rendering-util/layout-algorithms/common/index.js'; +// Exported for layout packages that live outside this one: an `afterPaint` +// hook is the only place line hops can be applied, and an external layout +// cannot reach into `rendering-util` the way the built-in ones do. +export { applyLineJumpsToSvg } from './rendering-util/rendering-elements/lineJump.js'; +export type { EdgeGeom, LineJumpConfig } from './rendering-util/rendering-elements/lineJump.js'; export interface RunOptions { /** diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js index 6d476fac9ad..0562127c6b8 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js @@ -46,16 +46,36 @@ function intersectLine(p1, p2, q1, q2) { return /*COLLINEAR*/; } - const offset = Math.abs(denom / 2); - - // The denom/2 is to get rounding instead of truncating. It - // is added or subtracted to the numerator, depending upon the - // sign of the numerator. - let num = b1 * c2 - b2 * c1; - const x = num < 0 ? (num - offset) / denom : (num + offset) / denom; - - num = a2 * c1 - a1 * c2; - const y = num < 0 ? (num - offset) / denom : (num + offset) / denom; + // The Graphics Gems original rounded here, for an INTEGER division: + // + // const offset = Math.abs(denom / 2); + // x = num < 0 ? (num - offset) / denom : (num + offset) / denom; + // + // Nudging the numerator by half a denominator away from zero is how you + // round rather than truncate when `/` floors. JavaScript's `/` does + // neither, so the nudge stopped being a correction and became the whole + // error: the result came out displaced by + // + // 0.5 * sign(num) * sign(denom) + // + // The magnitude is always exactly half a unit, but the SIGN is per axis, + // because `num` is computed separately for x and y while `denom` is shared. + // So the two axes could be displaced the same way or opposite ways + // depending on the geometry, which is why it never looked like a simple + // constant offset anyone could spot by eye. + // + // Half a pixel is invisible by itself, but `intersectPolygon` is how every + // non-rectangular shape finds its edge attachment, so it moved the point off + // the axis the query ray travelled along — enough to give an otherwise + // orthogonal edge a tiny diagonal opening segment, and to push an + // attachment just inside the node it was meant to touch. + // + // `question.ts` used to subtract a flat 0.5 from both axes to compensate. + // That only cancelled the bias when both signs happened to come out + // positive; when an axis was displaced the other way it doubled the error + // to a full unit instead. Both the bias and that compensation are gone. + const x = (b1 * c2 - b2 * c1) / denom; + const y = (a2 * c1 - a1 * c2) / denom; return { x: x, y: y }; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts new file mode 100644 index 00000000000..c0223a19afa --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import intersectLine from './intersect-line.js'; +import intersectPolygon from './intersect-polygon.js'; + +interface Point { + x: number; + y: number; +} + +/** + * `intersectLine` comes from Graphics Gems, where the coordinates were INTEGERS + * and `denom / 2` was added to the numerator so that the integer division + * rounded instead of truncating. JavaScript has no truncating division, so that + * term stopped being a rounding correction and became a constant half-unit + * displacement of every result. + * + * It matters because `intersectPolygon` is how every non-rectangular shape + * computes its edge attachment — diamond, stadium, hexagon, trapezoid, + * subroutine. Half a pixel is invisible on its own, but it puts the attachment + * point off the axis it was supposed to sit on, which turns an orthogonal edge + * into one with a tiny diagonal opening segment, and can place the point just + * inside the node it is supposed to touch. + */ +describe('intersectLine', () => { + it('intersects a vertical line with a horizontal segment exactly at the crossing', () => { + // Vertical line x = 10, horizontal segment y = 20. + const result = intersectLine( + { x: 10, y: 0 }, + { x: 10, y: 100 }, + { x: 0, y: 20 }, + { x: 50, y: 20 } + ); + + expect(result).toEqual({ x: 10, y: 20 }); + }); + + it('intersects a horizontal line with a vertical segment exactly at the crossing', () => { + const result = intersectLine( + { x: 0, y: 7 }, + { x: 100, y: 7 }, + { x: 33, y: 0 }, + { x: 33, y: 50 } + ); + + expect(result).toEqual({ x: 33, y: 7 }); + }); + + it('is unbiased whichever way the numerator and denominator sign out', () => { + // The removed arithmetic displaced a result by `0.5 * sign(num) * sign(denom)`, + // and `num` is computed per axis while `denom` is shared — so the old code + // could push x one way and y the other. These four cases put the same + // crossing in each sign combination by walking the segments in different + // directions; every one has to land on exactly the same point. + const expected = { x: 33, y: 57 }; + const combos: [Point, Point, Point, Point][] = [ + [ + { x: 33, y: 0 }, + { x: 33, y: 100 }, + { x: 0, y: 57 }, + { x: 100, y: 57 }, + ], + [ + { x: 33, y: 100 }, + { x: 33, y: 0 }, + { x: 0, y: 57 }, + { x: 100, y: 57 }, + ], + [ + { x: 33, y: 0 }, + { x: 33, y: 100 }, + { x: 100, y: 57 }, + { x: 0, y: 57 }, + ], + [ + { x: 33, y: 100 }, + { x: 33, y: 0 }, + { x: 100, y: 57 }, + { x: 0, y: 57 }, + ], + ]; + + for (const [p1, p2, q1, q2] of combos) { + const result = intersectLine(p1, p2, q1, q2); + expect(result, `${JSON.stringify([p1, p2, q1, q2])}`).toBeDefined(); + expect(result!.x).toBeCloseTo(expected.x, 9); + expect(result!.y).toBeCloseTo(expected.y, 9); + } + }); + + it('keeps the crossing on the query line for non-integer coordinates', () => { + // The case that shows up in real layouts: node centres are fractional, and + // the attachment must stay on the vertical ray leaving the node. + const x = 285.01588439941406; + // `intersectLine` returns undefined when the segments miss each other. + const result = intersectLine({ x, y: 34.5 }, { x, y: 67 }, { x: 0, y: 57 }, { x: 400, y: 57 }); + expect(result).toBeDefined(); + + expect(result!.x).toBeCloseTo(x, 9); + expect(result!.y).toBeCloseTo(57, 9); + }); +}); + +describe('intersectPolygon', () => { + it('attaches on the outline, on the axis the query ray travelled', () => { + // A diamond, 100x100, centred at (200, 200): vertices at the midpoints of + // its bounding box sides. A ray leaving the centre due east must attach at + // the east vertex exactly, not half a pixel off it in both axes. + const node = { x: 200, y: 200, width: 100, height: 100 }; + const points = [ + { x: 0, y: -50 }, + { x: 50, y: 0 }, + { x: 0, y: 50 }, + { x: -50, y: 0 }, + ]; + + const result = intersectPolygon(node, points, { x: 400, y: 200 }); + + expect(result.x).toBeCloseTo(250, 9); + expect(result.y).toBeCloseTo(200, 9); + }); +}); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts index 53cc9933f06..59179da7461 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts @@ -248,23 +248,24 @@ describe('lineJump', () => { }); it('shrinks jump radii when adjacent crossings would overlap', () => { - // Two crossings only 1.0 apart on a horizontal segment, but radius is - // 1.0 so a naive rewrite would invert the path. The two arcs must - // shrink to fit (each ≤ half the gap) and stay monotonic along the - // segment. + // Two crossings 1.6 apart on a horizontal segment, with radius 1.0 — a + // naive rewrite would invert the path. The two arcs must shrink to fit + // (each <= half the gap) and stay monotonic along the segment. 1.6 is + // wide enough that the shrunk radius still clears the minimum-useful + // bar; the case where it does not is the test below. const edges: EdgeGeom[] = [ { id: 'v1', points: [ - { x: 4.5, y: 0 }, - { x: 4.5, y: 10 }, + { x: 4.2, y: 0 }, + { x: 4.2, y: 10 }, ], }, { id: 'v2', points: [ - { x: 5.5, y: 0 }, - { x: 5.5, y: 10 }, + { x: 5.8, y: 0 }, + { x: 5.8, y: 10 }, ], }, { @@ -284,14 +285,49 @@ describe('lineJump', () => { expect(d.startsWith('M0,5 ')).toBe(true); expect(d.endsWith(' L10,5')).toBe(true); // Sweep flag 0 for horizontal +x segments, and radius clamped to at - // most half the 1.0 gap between the two crossings. + // most half the 1.6 gap between the two crossings. const firstArcMatch = /A([\d.]+),([\d.]+) 0 0 1 ([\d.]+),5/.exec(d); expect(firstArcMatch).not.toBeNull(); const firstArcRadius = parseFloat(firstArcMatch![1]); - expect(firstArcRadius).toBeLessThanOrEqual(0.5); + expect(firstArcRadius).toBeLessThanOrEqual(0.8); expect(firstArcRadius).toBeGreaterThan(0); }); + it('drops both hops when crossings are too close to carry one each', () => { + // The adjacency clamp can shrink a radius as far as a bend can, and a hop + // shrunk that way is just as unreadable — at production radius 6, two + // crossings 4px apart would each get 2px, which does not clear the stroke + // being hopped. Same rule, applied after the clamp as well as before it. + const edges: EdgeGeom[] = [ + { + id: 'v1', + points: [ + { x: 4.7, y: 0 }, + { x: 4.7, y: 10 }, + ], + }, + { + id: 'v2', + points: [ + { x: 5.3, y: 0 }, + { x: 5.3, y: 10 }, + ], + }, + { + id: 'h', + points: [ + { x: 0, y: 5 }, + { x: 10, y: 5 }, + ], + }, + ]; + + const d = processEdgesWithJumps(edges, ARC_CONFIG).get('h')!; + + expect(d).not.toMatch(/A/); + expect(d).toBe('M0,5 L10,5'); + }); + it('returns plain polylines for every edge when disabled, even with crossings present', () => { const edges: EdgeGeom[] = [ { @@ -686,4 +722,169 @@ describe('lineJump', () => { expect(e2.getAttribute('d')).toBe('M5,0 L5,10'); }); }); + + describe('hops with too little room next to a bend', () => { + const ROOMY: LineJumpConfig = { enabled: true, jumpRadius: 6, jumpStyle: 'arc' }; + + /** + * Geometry lifted from `elk-edge-cases/many-subgraphs-and-edges`, where + * `design-system -> mermaid-chart-app` leaves its node, turns north, turns + * east again at (430.6, 180.1), and is crossed 10px later at (440.6, 180.1) + * by `infrastructure -> auth-service`. + * + * 10px is `SUBGRAPH_EDGE_LANE_SPACING` — ELK stacks subgraph-internal edges + * in lanes that far apart — and it does not hold a 7.07px corner cut plus a + * 6px hop. The hop used to be fitted into what was left anyway, at 2.9px, + * starting exactly where the corner's quadratic ended. + */ + const CRAMPED: EdgeGeom[] = [ + { + id: 'designSystemToApp', + points: [ + { x: 395.6, y: 275.3 }, + { x: 430.6, y: 275.3 }, + { x: 430.6, y: 180.1 }, + { x: 502.1, y: 180.1 }, + ], + curve: 'rounded', + }, + { + id: 'infrastructureToAuth', + points: [ + { x: 440.6, y: 120 }, + { x: 440.6, y: 320 }, + ], + curve: 'rounded', + }, + ]; + + it('leaves the crossing alone rather than drawing an undersized arc', () => { + const d = processEdgesWithJumps(CRAMPED, ROOMY).get('designSystemToApp')!; + + // The crossing IS found — this is about what gets drawn for it, not about + // detection. + expect(findEdgeIntersections(CRAMPED)).toHaveLength(1); + + // No arc anywhere on the path, and no zero-length `L` parked on the + // corner's tangent point ahead of one. + expect(d).not.toMatch(/A/); + expect(d).not.toMatch(/L437\.696,180\.086 L437\.696,180\.086/); + }); + + it('still hops once the bend is far enough away', () => { + // Same edge, same crossing, but the turn moved back so the lane is 20px + // instead of 10px — now there is room for the full radius. + const roomy: EdgeGeom[] = [{ ...CRAMPED[0], points: [...CRAMPED[0].points] }, CRAMPED[1]]; + roomy[0].points[1] = { x: 420.6, y: 275.3 }; + roomy[0].points[2] = { x: 420.6, y: 180.1 }; + + const d = processEdgesWithJumps(roomy, ROOMY).get('designSystemToApp')!; + + expect(d).toContain('A6,6 0 0 1'); + }); + + /** + * `org -> platform` and `design -> app` from `knsv2.html`, verbatim. + * + * `org -> platform` runs east, turns south at (249, 1031.102) and carries on + * down. `design -> app` runs west along y=1033.625 and crosses that vertical + * — 2.5px below the turn, which is INSIDE the 7.07px the corner's quadratic + * takes to rejoin the line. + * + * So at the y where the hop was drawn, `org -> platform` is not on x=249 at + * all; it is still curving through its corner. The arc arched over blank + * paper while the two strokes carried on touching beside it. + */ + const ACROSS_A_CORNER: EdgeGeom[] = [ + { + id: 'orgToPlatform', + points: [ + { x: 169, y: 1031.102 }, + { x: 249, y: 1031.102 }, + { x: 249, y: 1345.091 }, + { x: 552.5, y: 1345.091 }, + ], + curve: 'rounded', + }, + { + id: 'designToApp', + points: [ + { x: 229, y: 1382.007 }, + { x: 229, y: 1033.625 }, + { x: 269, y: 1033.625 }, + { x: 351.5, y: 1033.625 }, + ], + curve: 'rounded', + }, + ]; + + it("ignores a crossing that lands inside the OTHER edge's corner", () => { + // Nothing is wrong with the hopping edge here: its own bend is 20px back, + // so the previous rule is happy to give it a full 6px arc. The problem is + // entirely on the edge being hopped. + expect(findEdgeIntersections(ACROSS_A_CORNER)).toEqual([]); + + const d = processEdgesWithJumps(ACROSS_A_CORNER, ROOMY).get('designToApp')!; + expect(d).not.toMatch(/A/); + }); + + it('hops normally once that corner is out of the way', () => { + // Same two edges, but `org -> platform` turns south 30px higher, so by + // y=1033.625 it has long since settled onto x=249 and there is a real + // vertical line to hop. + const clear: EdgeGeom[] = [ + { + ...ACROSS_A_CORNER[0], + points: [ + { x: 169, y: 1001.102 }, + { x: 249, y: 1001.102 }, + { x: 249, y: 1345.091 }, + { x: 552.5, y: 1345.091 }, + ], + }, + ACROSS_A_CORNER[1], + ]; + + expect(findEdgeIntersections(clear)).toHaveLength(1); + expect(processEdgesWithJumps(clear, ROOMY).get('designToApp')).toContain('A6,6'); + }); + + it('keeps a straight run between the corner and a hop it does draw', () => { + // A hop with just enough room still must not open ON the corner's tangent + // point — the quadratic and the arc would meet with nothing between them, + // which is the same squiggle as the undersized case, only bigger. + // + // The bend is at x=100, so its rounding ends at x=107.07. The crossing at + // x=113 leaves 5.93px, which the old clamp spent entirely on radius and + // opened the arc at exactly 107.07. + const edges: EdgeGeom[] = [ + { + id: 'bend', + points: [ + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 300, y: 100 }, + ], + curve: 'rounded', + }, + { + id: 'crosser', + points: [ + { x: 113, y: 0 }, + { x: 113, y: 200 }, + ], + }, + ]; + + const d = processEdgesWithJumps(edges, ROOMY).get('bend')!; + const arc = /L([\d.]+),100 A([\d.]+),/.exec(d); + expect(arc).not.toBeNull(); + + const [openAt, radius] = [arc![1], arc![2]].map(Number.parseFloat); + // Radius gives way, not the clearance: 2px of straight line survives + // between the end of the corner and the start of the arc. + expect(openAt - 107.071).toBeCloseTo(2, 2); + expect(radius).toBeCloseTo(3.929, 2); + }); + }); }); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts index fd4a34d9cfe..6b82c03f041 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/lineJump.ts @@ -21,6 +21,33 @@ const ROUNDED_CORNER_RADIUS = 5; * zero-length arcs on very crowded paths. */ const CORNER_EPSILON = 1e-5; +/** + * Straight run kept between a hop and the bend next to it. + * + * Without it a hop may start exactly at the tangent point of a rounded corner, + * so the path leaves the corner's quadratic and enters the arc with no straight + * run between them. The two curves read as one malformed squiggle rather than + * as a corner followed by a hop. + */ +const CORNER_JUMP_CLEARANCE = 2; + +/** + * Smallest share of the requested radius a hop may shrink to before it is + * dropped instead of drawn. + * + * A hop close to a bend has little room, and the clamps below will happily fit + * one into whatever is left. That is the wrong trade: an arc at half radius no + * longer clears the stroke it is meant to hop, so the lines still touch and the + * result looks like a rendering fault rather than a crossing. An undrawn hop is + * just an ordinary crossing, which is what every diagram looked like before + * hops existed — a much better failure than a broken-looking one. + * + * This happens for real: ELK routes subgraph-internal edges into lanes 10px + * apart, and a 10px offset cannot hold a 7.07px corner cut plus a 6px hop, so + * every crossing in such a lane was being drawn at 2.9px hard against the bend. + */ +const MIN_USEFUL_RADIUS_RATIO = 0.6; + export interface Point { x: number; y: number; @@ -133,6 +160,48 @@ function isHorizontalSeg(seg: Segment): boolean { return Math.abs(seg.b.x - seg.a.x) >= Math.abs(seg.b.y - seg.a.y); } +/** + * True if a crossing on `edge`'s segment `segIndex` at parameter `t` falls + * inside the stretch where the drawn stroke has left the polyline to round a + * bend. + * + * Crossings are found on polylines, but a `rounded` edge is not drawn as its + * polyline: `generateRoundedPath` replaces each bend with a quadratic that + * departs the line up to `cutLen` before the vertex and rejoins it `cutLen` + * after. Inside that stretch the polyline says the stroke is somewhere it is + * not, so a "crossing" computed there is at best mislocated and at worst + * fictional — and a hop drawn for it arches over blank paper while the two + * strokes still touch alongside it. + * + * Only `rounded` edges lie this way; every other supported curve is drawn as + * the polyline it describes. + */ +function crossingSitsInRoundedCorner(edge: EdgeGeom, segIndex: number, t: number): boolean { + if (edge.curve !== 'rounded') { + return false; + } + const pts = edge.points; + const a = pts[segIndex]; + const b = pts[segIndex + 1]; + if (!a || !b) { + return false; + } + const segLen = Math.hypot(b.x - a.x, b.y - a.y); + const d = t * segLen; + + const entering = + segIndex > 0 ? computeRoundedCorner(pts[segIndex - 1], a, b, ROUNDED_CORNER_RADIUS) : null; + if (entering && d < entering.cutLen) { + return true; + } + + const leaving = + segIndex + 2 < pts.length + ? computeRoundedCorner(a, b, pts[segIndex + 2], ROUNDED_CORNER_RADIUS) + : null; + return leaving !== null && segLen - d < leaving.cutLen; +} + export function findEdgeIntersections(edges: EdgeGeom[]): Crossing[] { const crossings: Crossing[] = []; @@ -150,6 +219,15 @@ export function findEdgeIntersections(edges: EdgeGeom[]): Crossing[] { continue; } + // Either edge rounding a bend here means the polyline is not where + // the stroke is, so there is nothing trustworthy to hop over. + if ( + crossingSitsInRoundedCorner(edgeA, si, hit.tA) || + crossingSitsInRoundedCorner(edgeB, sj, hit.tB) + ) { + continue; + } + // Orthogonal-orientation rule: when one segment is horizontal- // dominant and the other vertical-dominant, the HORIZONTAL one // gets the jump (classic line-hop convention — arcs arch upward @@ -224,8 +302,6 @@ interface JumpOnSegment { r: number; } -const MIN_JUMP_RADIUS = 1e-3; - /** * Shifts the first/last point inward along the edge direction by the amount * required for their arrow markers, matching `applyMarkerOffsetsToPoints` in @@ -410,12 +486,18 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf } } - // Jumps clamped so they don't overlap corners at either end of the - // segment or each other. - const segJumps = [...(bySeg.get(i) ?? [])].sort((a, b) => a.t - b.t); - for (const j of segJumps) { - j.r = Math.min(j.r, j.d - segStartConsumed, segEndStop - j.d); - } + // Clamp each jump to the room between the bends at either end of the + // segment, then drop the ones with too little room to be worth drawing. + // Dropping happens BEFORE the adjacency pass below so that a hop being + // squeezed out by a corner does not also shrink its neighbours. + const minUsefulRadius = config.jumpRadius * MIN_USEFUL_RADIUS_RATIO; + const segJumps = [...(bySeg.get(i) ?? [])] + .sort((a, b) => a.t - b.t) + .filter((j) => { + const room = Math.min(j.d - segStartConsumed, segEndStop - j.d) - CORNER_JUMP_CLEARANCE; + j.r = Math.min(j.r, room); + return j.r >= minUsefulRadius; + }); for (let k = 0; k < segJumps.length - 1; k++) { const gap = segJumps[k + 1].d - segJumps[k].d; if (segJumps[k].r + segJumps[k + 1].r > gap) { @@ -426,7 +508,11 @@ function rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConf } for (const j of segJumps) { - if (j.r < MIN_JUMP_RADIUS) { + // Checked AGAIN after the adjacency pass, not only before it. That pass + // can halve a radius to keep two hops off each other, and a hop shrunk + // that way is just as unreadable as one squeezed by a bend — same rule, + // both times. Two crossings too close to carry a hop each carry none. + if (j.r < minUsefulRadius) { continue; } parts.push(...emitJump(j, ux, uy, sweep, config.jumpStyle)); @@ -580,15 +666,25 @@ export function applyLineJumpsToSvg( // Collect geometry from each path's data-points, preferring that over the // incoming `edges[].points` which came from pre-render layout state. + // Index the paths by their own `data-id` instead of building one selector per + // edge. An id is author-controlled, so interpolating it into a selector needs + // `CSS.escape`, which is not guaranteed outside a browser — and the fallback + // of using the id raw turns a trailing backslash into a `SyntaxError` that + // aborts the whole render. Reading the attribute avoids the selector entirely. + const pathByDataId = new Map(); + for (const el of groupNode.querySelectorAll('path[data-id]')) { + const id = el.getAttribute('data-id'); + if (id !== null && !pathByDataId.has(id)) { + pathByDataId.set(id, el); + } + } + const renderedEdges: EdgeGeom[] = []; - const pathById = new Map(); for (const e of edges) { - const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(e.id) : e.id; - const pathEl = groupNode.querySelector(`path[data-id="${escapedId}"]`); + const pathEl = pathByDataId.get(e.id); if (!pathEl) { continue; } - pathById.set(e.id, pathEl); const decoded = decodeDataPoints(pathEl.getAttribute('data-points')); const points = decoded ?? e.points; renderedEdges.push({ ...e, points }); @@ -617,7 +713,7 @@ export function applyLineJumpsToSvg( continue; } - const pathEl = pathById.get(renderedEdge.id); + const pathEl = pathByDataId.get(renderedEdge.id); if (!pathEl) { continue; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts index 988d0794782..10dc0d7cfc9 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts @@ -72,10 +72,15 @@ export async function question(parent: D3Selection { x: 0, y: -s / 2 }, ]; - // Calculate the intersection point - const res = intersect.polygon(bounds, points, point); - - return { x: res.x - 0.5, y: res.y - 0.5 }; // Adjusted result + // Calculate the intersection point. + // + // This used to return `res` shifted by a flat -0.5 on both axes, to + // compensate for leftover integer-rounding arithmetic in `intersectLine`. + // That arithmetic displaced a result by `0.5 * sign(num) * sign(denom)`, + // whose sign varies per axis, so a fixed subtraction only cancelled it when + // both signs came out positive and doubled it to a full unit when they did + // not. The displacement is gone, so the compensation goes with it. + return intersect.polygon(bounds, points, point); }; node.intersect = function (point) { diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index f700b60f732..e4e137fa4c3 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -132,7 +132,7 @@ properties: - NETWORK_SIMPLEX - LINEAR_SEGMENTS - BRANDES_KOEPF - default: BRANDES_KOEPF + # Default comes from `elk.preset` unless set explicitly. nodePlacementAlignment: description: | Elk specific option affecting Brandes-Koepf node placement alignment. @@ -146,6 +146,101 @@ properties: - RIGHTDOWN - BALANCED default: NONE + preset: + description: | + Named combination of the three options that decide where nodes end up: + layering strategy, node placement strategy and cycle breaking strategy. + They belong to different phases of the layout, so a preset is simply a + named triple rather than a mode with behaviour of its own. + + `default` — network simplex layering and placement with depth-first + cycle breaking at the top level, and Brandes-Koepf placement inside + subgraphs. Depth-first breaking gives shorter back edges on graphs + that loop. + + `legacy` — what shipped before presets existed: Brandes-Koepf placement, + which straightens long edges at the cost of that alignment, with ELK's + own greedy cycle breaking. Reproduces the rendering of earlier + versions rather than the defaults their schema advertised. + + `modelOrder` — as `default`, but breaks cycles by greedy model order, + which disturbs declaration order least at the cost of longer back + edges. This is the combination `default` named previously. + + `depthFirst` — a name for what `default` already is, for diagrams that + would rather say depth-first than rely on the default. + + Setting `layeringStrategy`, `nodePlacementStrategy` or + `cycleBreakingStrategy` explicitly overrides the preset for that one + option; the rest of the preset still applies. + type: string + enum: + - default + - legacy + - modelOrder + - depthFirst + default: default + straightenEdges: + description: | + Straightens an edge that leaves or enters a node with a tiny step. + + ELK spreads an edge's port evenly along a node's side but routes the + edge down a channel whose row rarely lines up with that port exactly, + leaving a staircase of a few pixels right at the border. With rounded + corners the two micro-bends land on top of each other and read as a + kink. Enabling this moves the channel onto the port's row and drops + the step, so the edge draws as one straight line and both ports stay + exactly where the layout put them. + + Only the step next to a node is touched, and only when the edge + continues the same way afterwards, so a real turn is never collapsed. + An edge is left alone entirely when moving its run would drag the far + port, or would introduce a crossing. + type: boolean + default: true + lineHops: + description: | + Renders edge crossings as small arcs ("hops") or visible gaps, so that + it is clear which line passes over which where two edges meet. + + The edge that gives way loses its corner rounding for the segment + carrying the hop, which is the trade for a readable crossing. Curved + edges are skipped rather than rewritten, to avoid corrupting their + geometry. Set to `false` to draw plain crossings. + oneOf: + - type: boolean + - type: string + enum: ['arc', 'gap'] + default: true + layeringStrategy: + description: | + Elk specific option deciding which layer each node is assigned to — the + column in a left-to-right diagram, the row in a top-down one. This is + the coarsest of the three placement decisions, so changing it moves + nodes further than anything else short of altering spacing. + + NETWORK_SIMPLEX aims for the fewest long edges. LONGEST_PATH pushes + every node as late as it can go. COFFMAN_GRAHAM bounds how many nodes + share a layer, giving a more even, block-like shape on wide graphs. + MIN_WIDTH and STRETCH_WIDTH trade edge length for a narrower or wider + drawing. INTERACTIVE honours positions already on the nodes. + type: string + enum: + - NETWORK_SIMPLEX + - LONGEST_PATH + - LONGEST_PATH_SOURCE + - COFFMAN_GRAHAM + - MIN_WIDTH + - STRETCH_WIDTH + - INTERACTIVE + # Default comes from `elk.preset` unless set explicitly. + layeringLayerBound: + description: | + Elk specific option capping how many nodes COFFMAN_GRAHAM will put in + one layer. Ignored by every other layering strategy. Lower values give + a taller, narrower drawing. + type: number + default: 4 cycleBreakingStrategy: description: | This strategy decides how to find cycles in the graph and deciding which edges need adjustment to break loops. @@ -156,7 +251,7 @@ properties: - INTERACTIVE - MODEL_ORDER - GREEDY_MODEL_ORDER - default: GREEDY_MODEL_ORDER + # Default comes from `elk.preset` unless set explicitly. forceNodeModelOrder: description: | The node order given by the model does not change to produce a better layout. E.g. if node A is before node B in the model this is not changed during crossing minimization. This assumes that the node model order is already respected before crossing minimization. This can be achieved by setting considerModelOrder.strategy to NODES_AND_EDGES. diff --git a/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts new file mode 100644 index 00000000000..f8475317377 --- /dev/null +++ b/packages/mermaid/src/themes/theme-redux-color-superset.spec.ts @@ -0,0 +1,188 @@ +/** + * `redux-color` / `redux-dark-color` are the colour-carrying siblings of `redux` / + * `redux-dark`: they add `borderColorArray` and a real categorical palette on top of the + * same geometry and typography. Only the light theme adds `bkgColorArray` -- see the + * array test below for why the dark one deliberately does not. + * + * They were forked by copy-paste, so they had drifted: seven variables `redux` + * defines were either missing (`stateEdgeLabelBackground`, + * `requirementEdgeLabelBackground`) or silently re-derived from the grey + * `primaryColor` (`primaryBorderColor`, `clusterBkg`, `clusterBorder`, + * `altBackground`, `compositeTitleBackground`). Nothing crashes when that + * happens — diagrams just pick up an untuned value where the base theme has a + * deliberate one, which is invisible in review and only shows up in a screenshot. + * + * This pins two properties: + * + * 1. A colour theme defines everything its base theme defines. + * 2. It diverges *only* on the palette it exists to provide — and that palette is + * listed here explicitly, so widening it is a deliberate edit to this file + * rather than something a loose pattern lets through unnoticed. + */ +// khroma's `dist/index.d.ts` re-exports everything via a bare `export * from './methods'`. +// Under this repo's `module: nodenext` that path is resolved as ESM, where a relative +// specifier without a file extension does not resolve -- so TS sees the module as having no exports at all +// and every member of this import errors. The members do exist (khroma +// `dist/methods/index.d.ts` exports them) and the import works at runtime; an isolated +// `tsc --moduleResolution bundler` also compiles it clean, which is why this looks +// unnecessary until `pnpm build:types` runs. +// +// `@ts-expect-error` rather than `@ts-ignore` deliberately: if khroma ships resolvable +// types or the module setting changes, this fails and gets deleted instead of lingering. +// @ts-expect-error -- see above +import { hue, isDark, toRgba } from 'khroma'; +import { describe, expect, it } from 'vitest'; +import themes from './index.js'; + +/** + * The variables a colour theme is meant to own. Everything else has to match its + * base theme exactly. + */ +const PALETTE_VARS = new Set([ + // Categorical scale: mindmap, kanban, treemap, radar, timeline. + ...Array.from({ length: 12 }, (_, i) => `cScale${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScaleInv${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScalePeer${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScaleLabel${i}`), + // Pie slices are drawn from the categorical scale. + ...Array.from({ length: 12 }, (_, i) => `pie${i + 1}`), + // …which means the in-slice label ink is a palette decision too. + 'pieSectionTextColor', + // User-journey task and section fills. + ...Array.from({ length: 8 }, (_, i) => `fillType${i}`), + // Gantt section banding. + 'sectionBkgColor', + 'sectionBkgColor2', + // The third gantt band, and an exemption the *dark* pair needs only. Both base themes + // set it to 'white', which is right on a white canvas: at the 20% opacity gantt paints + // bands with it composites to nothing, so every other band reads as absent. On the dark + // canvas the same literal composites to rgb(92,92,92) -- a grey brighter than either + // tuned hue, so half of every gantt's banding fought the other half -- and the dark + // theme therefore uses its canvas colour instead. The light theme keeps 'white' + // verbatim, so it does not diverge and this exemption goes unused for that pair. + 'altSectionBkgColor', +]); + +const PAIRS = [ + ['redux', 'redux-color'], + ['redux-dark', 'redux-dark-color'], +] as const; + +/** + * How many `bkgColorArray` entries each colour theme ships. The asymmetry is deliberate, + * not leftover drift: `bkgColorArray` is what gates *fills* in `er/styles.ts`, + * `requirement/styles.js` and `sequence/svgDraw.js`, and the dark theme intentionally + * colours only borders there, leaving box interiors on the dark canvas. Populating it + * would silently repaint ER entities, requirement boxes and sequence actors. + * + * It is asserted per theme rather than left unchecked so that the difference stays a + * recorded decision. An earlier version of this spec checked only `borderColorArray`, + * which meant the one asymmetry between the two themes passed the very test written to + * catch drift between them. + */ +const EXPECTED_BKG_COLORS: Record = { + 'redux-color': 12, + 'redux-dark-color': 0, +}; + +describe.each(PAIRS)('%s -> %s', (baseName, colorName) => { + const base = themes[baseName].getThemeVariables({}) as unknown as Record; + const color = themes[colorName].getThemeVariables({}) as unknown as Record; + + it(`${colorName} defines every variable ${baseName} defines`, () => { + const missing = Object.keys(base).filter( + (key) => typeof base[key] !== 'function' && color[key] === undefined + ); + expect(missing).toEqual([]); + }); + + it(`${colorName} only diverges from ${baseName} on the palette`, () => { + const unexpected = Object.keys(base).filter( + (key) => + typeof base[key] !== 'function' && + !PALETTE_VARS.has(key) && + JSON.stringify(base[key]) !== JSON.stringify(color[key]) + ); + expect(unexpected).toEqual([]); + }); + + it(`${colorName} provides the colour arrays ${baseName} does not`, () => { + expect(base.borderColorArray).toBeUndefined(); + expect(base.bkgColorArray).toBeUndefined(); + expect(color.borderColorArray).toHaveLength(12); + expect(color.bkgColorArray).toHaveLength(EXPECTED_BKG_COLORS[colorName]); + }); +}); + +/** + * The chart diagrams read flat `pieN` / `fillTypeN` variables rather than the + * `cScale` array, so they were the last diagrams still rendering monochrome under + * the colour themes: every `pieN` was a tint of one pale lavender (`pie3` resolved + * to pure white in `redux-color`, and to near-black in `redux-dark-color`). + * + * "12 distinct values" alone would have passed before this was fixed — the tints + * *were* distinct, just indistinguishable. So assert real separation instead. + */ +/** The opacity `gantt/styles.js` paints its `.section` bands at. */ +const GANTT_BAND_OPACITY = 0.2; + +const channels = (color: string): number[] => + (toRgba(color).match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + +/** Flatten a colour onto a background at the given alpha, as the browser would. */ +const compositeOver = (foreground: string, background: string, alpha: number): number[] => { + const fg = channels(foreground); + const bg = channels(background); + return fg.map((value, i) => Math.round(alpha * value + (1 - alpha) * bg[i])); +}; + +const maxChannelDelta = (a: number[], b: number[]): number => + Math.max(...a.map((value, i) => Math.abs(value - b[i]))); + +describe.each(['redux-color', 'redux-dark-color'] as const)('%s chart palettes', (name) => { + const vars = themes[name].getThemeVariables({}) as unknown as Record; + + it('draws pie slices from the categorical scale', () => { + const slices = Array.from({ length: 12 }, (_, i) => vars[`pie${i + 1}`]); + const scale = Array.from({ length: 12 }, (_, i) => vars[`cScale${i}`]); + expect(slices).toEqual(scale); + }); + + it('gives user-journey a distinct hue per section', () => { + // Not "eight distinct values": the old tints were eight distinct values too -- four + // hues repeated in pairs -- so a `Set` of the strings passes against the broken code. + // One hue per section is the property that was actually missing. (In the dark theme + // the old fills were also all under 5% saturation, i.e. greyscale.) + const fills = Array.from({ length: 8 }, (_, i) => vars[`fillType${i}`]); + expect(fills.every((fill) => typeof fill === 'string' && fill.length > 0)).toBe(true); + const hues = fills.map((fill) => Math.round(hue(fill))); + expect(new Set(hues).size).toBe(8); + }); + + it('bands gantt sections with two visibly different colours', () => { + // `gantt/styles.js` paints bands at 20% opacity, so what matters is how the two + // composite over the canvas -- not whether the source strings differ. On the previous + // code they differed as strings while compositing to a max-channel delta of 4 in the + // light theme and 0 in the dark one: no visible banding at all, which a `not.toBe` + // assertion accepts without complaint. The tuned bands land at 35 in both. + const band0 = compositeOver(vars.sectionBkgColor, vars.background, GANTT_BAND_OPACITY); + const band2 = compositeOver(vars.sectionBkgColor2, vars.background, GANTT_BAND_OPACITY); + expect(maxChannelDelta(band0, band2)).toBeGreaterThanOrEqual(16); + }); + + /** + * User-journey paints task labels with the theme's `textColor` (verified against the + * rendered DOM, not the stylesheet -- `user-journey/styles.js` also carries a + * hardcoded `.label text { fill: #333 }` rule that does not win). So a light theme + * needs light fills and a dark theme needs dark ones. Picking fills off the shared + * categorical scale gets this wrong in the dark theme: the labels end up light ink on + * a light fill. + */ + it('keeps user-journey fills on the opposite side of the label ink', () => { + const inkIsDark = isDark(vars.textColor); + const wrong = Array.from({ length: 8 }, (_, i) => `fillType${i}`).filter( + (key) => isDark(vars[key]) === inkIsDark + ); + expect(wrong).toEqual([]); + }); +}); diff --git a/packages/mermaid/src/themes/theme-redux-color.js b/packages/mermaid/src/themes/theme-redux-color.js index ca97f628016..12620ab3b71 100644 --- a/packages/mermaid/src/themes/theme-redux-color.js +++ b/packages/mermaid/src/themes/theme-redux-color.js @@ -24,7 +24,7 @@ class Theme { this.radius = 12; this.strokeWidth = 2; - this.primaryBorderColor = mkBorder(this.primaryColor, this.darkMode); + this.primaryBorderColor = mkBorder('#28253D', this.darkMode); // dark this.fontFamily = '"Recursive Variable", arial, sans-serif'; @@ -40,6 +40,10 @@ class Theme { this.nodeShadow = true; this.tertiaryColor = '#ffffff'; + /* Class Diagram variables */ + this.clusterBkg = '#F9F9FB'; + this.clusterBorder = '#BDBCCC'; + /* Architecture Diagram variables */ this.archEdgeColor = 'calculated'; this.archEdgeArrowColor = 'calculated'; @@ -147,10 +151,6 @@ class Theme { const primaryColor = '#ECECFE'; const secondaryColor = '#E9E9F1'; const tertiaryColor = adjust(primaryColor, { h: 180, l: 5 }); - this.sectionBkgColor = this.sectionBkgColor || tertiaryColor; - this.altSectionBkgColor = this.altSectionBkgColor || 'white'; - this.sectionBkgColor = this.sectionBkgColor || secondaryColor; - this.sectionBkgColor2 = this.sectionBkgColor2 || primaryColor; this.excludeBkgColor = this.excludeBkgColor || '#eeeeee'; this.taskBorderColor = this.taskBorderColor || this.primaryBorderColor; this.taskBkgColor = this.taskBkgColor || primaryColor; @@ -184,12 +184,12 @@ class Theme { this.transitionLabelColor = this.transitionLabelColor || this.textColor; /* The color of the text tables of the states*/ this.stateLabelColor = this.stateLabelColor || this.stateBkg || this.primaryTextColor; - + this.stateEdgeLabelBackground = this.stateEdgeLabelBackground || '#FFFFFF'; this.stateBkg = this.stateBkg || this.mainBkg; this.labelBackgroundColor = this.labelBackgroundColor || this.stateBkg; this.compositeBackground = this.compositeBackground || this.background || this.tertiaryColor; - this.altBackground = this.altBackground || '#f0f0f0'; - this.compositeTitleBackground = this.compositeTitleBackground || this.mainBkg; + this.altBackground = this.altBackground || '#F9F9FB'; + this.compositeTitleBackground = this.compositeTitleBackground || '#F9F9FB'; this.compositeBorder = this.compositeBorder || this.nodeBorder; this.innerEndBackground = this.nodeBorder; this.errorBkgColor = this.errorBkgColor || this.tertiaryColor; @@ -223,6 +223,26 @@ class Theme { // } // } + /* Gantt chart section banding. + * + * gantt/styles.js cycles four band classes: .section0 -> sectionBkgColor, + * .section1 and .section3 -> altSectionBkgColor, .section2 -> sectionBkgColor2. All + * are painted at 20% opacity, so the source colour has to be saturated to read at + * all -- the `primaryColor` tints used before composited to nothing. + * + * Assigned here rather than in the gantt block above so the two visible bands come + * off the categorical scale and follow it if the palette changes. + * + * altSectionBkgColor keeps the base theme's 'white' verbatim rather than being + * restated as `this.background`. It is the same colour on this canvas, and every other + * band reading as absent is already the correct behaviour here -- so leaving the string + * untouched keeps this variable out of the drift net's exemption list for this pair. + * The dark theme is where 'white' is wrong and has to change. + */ + this.sectionBkgColor = this.sectionBkgColor || this.cScale0; + this.sectionBkgColor2 = this.sectionBkgColor2 || this.cScale1; + this.altSectionBkgColor = this.altSectionBkgColor || 'white'; + // Setup the inverted color for the set for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { this['cScaleInv' + i] = this['cScaleInv' + i] || invert(this['cScale' + i]); @@ -257,28 +277,23 @@ class Theme { this.classText = this.classText || this.textColor; /* user-journey */ - this.fillType0 = this.fillType0 || primaryColor; - this.fillType1 = this.fillType1 || secondaryColor; - this.fillType2 = this.fillType2 || adjust(primaryColor, { h: 64 }); - this.fillType3 = this.fillType3 || adjust(secondaryColor, { h: 64 }); - this.fillType4 = this.fillType4 || adjust(primaryColor, { h: -64 }); - this.fillType5 = this.fillType5 || adjust(secondaryColor, { h: -64 }); - this.fillType6 = this.fillType6 || adjust(primaryColor, { h: 128 }); - this.fillType7 = this.fillType7 || adjust(secondaryColor, { h: 128 }); + // Journey task labels resolve to the theme's `textColor` (#28253D here), so these + // fills have to stay light. user-journey/styles.js also carries a hardcoded + // `.label text { fill: #333 }` rule, but it does not win -- confirmed against the + // rendered DOM, not read off the stylesheet. The pale background array keeps the + // fills hue-distinct where the old tints of `primaryColor` were eight shades of the + // same lavender. + for (let i = 0; i < 8; i++) { + this['fillType' + i] = this['fillType' + i] || this.bkgColorArray[i]; + } /* pie */ - this.pie1 = this.pie1 || primaryColor; - this.pie2 = this.pie2 || secondaryColor; - this.pie3 = this.pie3 || tertiaryColor; - this.pie4 = this.pie4 || adjust(primaryColor, { l: -10 }); - this.pie5 = this.pie5 || adjust(secondaryColor, { l: -10 }); - this.pie6 = this.pie6 || adjust(tertiaryColor, { l: -10 }); - this.pie7 = this.pie7 || adjust(primaryColor, { h: +60, l: -10 }); - this.pie8 = this.pie8 || adjust(primaryColor, { h: -60, l: -10 }); - this.pie9 = this.pie9 || adjust(primaryColor, { h: 120, l: 0 }); - this.pie10 = this.pie10 || adjust(primaryColor, { h: +60, l: -20 }); - this.pie11 = this.pie11 || adjust(primaryColor, { h: -60, l: -20 }); - this.pie12 = this.pie12 || adjust(primaryColor, { h: 120, l: -10 }); + // Slices reuse the theme's categorical scale so a pie reads like a mindmap or + // treemap in the same theme. The old tints of `primaryColor` were all near-white + // (pie3 resolved to pure white), leaving adjacent slices indistinguishable. + for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { + this['pie' + (i + 1)] = this['pie' + (i + 1)] || this['cScale' + i]; + } this.pieTitleTextSize = this.pieTitleTextSize || '25px'; this.pieTitleTextColor = this.pieTitleTextColor || this.taskTextDarkColor; this.pieSectionTextSize = this.pieSectionTextSize || '17px'; @@ -348,6 +363,7 @@ class Theme { this.relationLabelBackground || (this.darkMode ? darken(this.secondaryColor, 30) : this.secondaryColor); this.relationLabelColor = this.relationLabelColor || this.actorTextColor; + this.requirementEdgeLabelBackground = '#FFFFFF'; /* git */ this.git0 = this.git0 || primaryColor; diff --git a/packages/mermaid/src/themes/theme-redux-dark-color.js b/packages/mermaid/src/themes/theme-redux-dark-color.js index c3a071c7d4e..fc558ee3e5c 100644 --- a/packages/mermaid/src/themes/theme-redux-dark-color.js +++ b/packages/mermaid/src/themes/theme-redux-dark-color.js @@ -156,10 +156,6 @@ class Theme { /* Gantt chart variables */ - this.sectionBkgColor = this.sectionBkgColor || this.tertiaryColor; - this.altSectionBkgColor = this.altSectionBkgColor || 'white'; - this.sectionBkgColor = this.sectionBkgColor || this.secondaryColor; - this.sectionBkgColor2 = this.sectionBkgColor2 || this.primaryColor; this.excludeBkgColor = this.excludeBkgColor || '#eeeeee'; this.taskBorderColor = this.taskBorderColor || this.primaryBorderColor; this.taskBkgColor = this.taskBkgColor || this.primaryColor; @@ -193,12 +189,12 @@ class Theme { this.transitionLabelColor = this.transitionLabelColor || this.textColor; /* The color of the text tables of the states*/ this.stateLabelColor = this.stateLabelColor || this.stateBkg || this.primaryTextColor; - + this.stateEdgeLabelBackground = this.stateEdgeLabelBackground || '#16141F'; this.stateBkg = this.stateBkg || this.mainBkg; this.labelBackgroundColor = this.labelBackgroundColor || this.stateBkg; - this.compositeBackground = this.compositeBackground || this.background || this.tertiaryColor; - this.altBackground = this.altBackground || '#f0f0f0'; - this.compositeTitleBackground = this.compositeTitleBackground || this.mainBkg; + this.compositeBackground = this.compositeBackground || '#16141F'; + this.altBackground = this.altBackground || '#16141F'; + this.compositeTitleBackground = this.compositeTitleBackground || '#16141F'; this.compositeBorder = this.compositeBorder || this.nodeBorder; this.innerEndBackground = this.nodeBorder; this.errorBkgColor = this.errorBkgColor || this.tertiaryColor; @@ -231,6 +227,25 @@ class Theme { // } // } + /* Gantt chart section banding. + * + * gantt/styles.js cycles four band classes: .section0 -> sectionBkgColor, + * .section1 and .section3 -> altSectionBkgColor, .section2 -> sectionBkgColor2. All + * are painted at 20% opacity, so the source colour has to be saturated to read at + * all -- the `primaryColor` tints used before composited to nothing. + * + * Assigned here rather than in the gantt block above so the two visible bands come + * off the categorical scale and follow it if the palette changes. + * + * altSectionBkgColor is the canvas colour, not the inherited 'white': at 20% over + * the #333 canvas white composites to rgb(92,92,92), a grey brighter than both tuned + * hues, so half of every gantt's banding fought the other half. The canvas colour + * makes those bands read as absent instead, matching the light theme. + */ + this.sectionBkgColor = this.sectionBkgColor || this.cScale0; + this.sectionBkgColor2 = this.sectionBkgColor2 || this.cScale1; + this.altSectionBkgColor = this.altSectionBkgColor || this.background; + // Setup the inverted color for the set for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { this['cScaleInv' + i] = this['cScaleInv' + i] || invert(this['cScale' + i]); @@ -265,32 +280,41 @@ class Theme { this.classText = this.classText || this.textColor; /* user-journey */ - this.fillType0 = this.fillType0 || this.primaryColor; - this.fillType1 = this.fillType1 || this.secondaryColor; - this.fillType2 = this.fillType2 || adjust(this.primaryColor, { h: 64 }); - this.fillType3 = this.fillType3 || adjust(this.secondaryColor, { h: 64 }); - this.fillType4 = this.fillType4 || adjust(this.primaryColor, { h: -64 }); - this.fillType5 = this.fillType5 || adjust(this.secondaryColor, { h: -64 }); - this.fillType6 = this.fillType6 || adjust(this.primaryColor, { h: 128 }); - this.fillType7 = this.fillType7 || adjust(this.secondaryColor, { h: 128 }); + // Journey paints the section band and the task rects inside it with the same + // `fillTypeN`, and task labels use the light `textColor`, so these fills have to + // stay dark -- a saturated fill collapses band and task into one block and swamps + // the labels. `bkgColorArray` is empty in this theme, so there is no pale array to + // draw on, and darkening the categorical scale by a fixed amount does not work + // either: the 300-shades differ too much in luminance, so teal lands near black + // while fuchsia stays clearly purple. Fixed 900-shades of the same hue order give + // even weight across the eight sections. + const journeyFills = [ + '#701a75', // Fuchsia-900 + '#134e4a', // Teal-900 + '#7c2d12', // Orange-900 + '#581c87', // Purple-900 + '#14532d', // Green-900 + '#4c1d95', // Violet-900 + '#7f1d1d', // Red-900 + '#713f12', // Yellow-900 + ]; + journeyFills.forEach((fill, i) => { + this['fillType' + i] = this['fillType' + i] || fill; + }); /* pie */ - this.pie1 = this.pie1 || this.primaryColor; - this.pie2 = this.pie2 || this.secondaryColor; - this.pie3 = this.pie3 || this.tertiaryColor; - this.pie4 = this.pie4 || adjust(this.primaryColor, { l: -10 }); - this.pie5 = this.pie5 || adjust(this.secondaryColor, { l: -10 }); - this.pie6 = this.pie6 || adjust(this.tertiaryColor, { l: -10 }); - this.pie7 = this.pie7 || adjust(this.primaryColor, { h: +60, l: -10 }); - this.pie8 = this.pie8 || adjust(this.primaryColor, { h: -60, l: -10 }); - this.pie9 = this.pie9 || adjust(this.primaryColor, { h: 120, l: 0 }); - this.pie10 = this.pie10 || adjust(this.primaryColor, { h: +60, l: -20 }); - this.pie11 = this.pie11 || adjust(this.primaryColor, { h: -60, l: -20 }); - this.pie12 = this.pie12 || adjust(this.primaryColor, { h: 120, l: -10 }); + // Slices reuse the theme's categorical scale so a pie reads like a mindmap or + // treemap in the same theme. The old tints of `primaryColor` were all near-black + // on a dark canvas, leaving adjacent slices indistinguishable. + for (let i = 0; i < this.THEME_COLOR_LIMIT; i++) { + this['pie' + (i + 1)] = this['pie' + (i + 1)] || this['cScale' + i]; + } this.pieTitleTextSize = this.pieTitleTextSize || '25px'; this.pieTitleTextColor = this.pieTitleTextColor || this.taskTextDarkColor; this.pieSectionTextSize = this.pieSectionTextSize || '17px'; - this.pieSectionTextColor = this.pieSectionTextColor || this.textColor; + // Slice fills are now the bright categorical scale, so the in-slice label needs a + // dark ink rather than the theme's light `textColor`. + this.pieSectionTextColor = this.pieSectionTextColor || '#28253D'; this.pieLegendTextSize = this.pieLegendTextSize || '17px'; this.pieLegendTextColor = this.pieLegendTextColor || this.taskTextDarkColor; this.pieStrokeColor = this.pieStrokeColor || 'black'; @@ -356,6 +380,7 @@ class Theme { this.relationLabelBackground || (this.darkMode ? darken(this.secondaryColor, 30) : this.secondaryColor); this.relationLabelColor = this.relationLabelColor || this.actorTextColor; + this.requirementEdgeLabelBackground = '#16141F'; /* git */ this.git0 = this.git0 || this.primaryColor; diff --git a/packages/mermaid/src/type.d.ts b/packages/mermaid/src/type.d.ts index 71a50368d92..a107a69259f 100644 --- a/packages/mermaid/src/type.d.ts +++ b/packages/mermaid/src/type.d.ts @@ -9,3 +9,13 @@ declare var injected: { */ profiling: boolean; }; + +/** + * Vite's `?raw` suffix, which yields a module's own source as a string. Used by specs that + * assert the *shape* of a source file rather than its behaviour -- see + * `diagrams/sequence/palettePicking.spec.ts`. + */ +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/playwright.config.ts b/playwright.config.ts index fab300dc440..7f9ed76424c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,11 @@ process.env.MERMAID_DEV_PORT ??= port; const devCommand = process.env.E2E_COVERAGE ? 'pnpm dev:coverage' : 'pnpm dev'; +// Seeded in the runner process so every worker inherits the same id. +if (process.env.USE_APPLI) { + process.env.APPLITOOLS_BATCH_ID ??= Date.now().toString(); +} + export default defineConfig({ testDir: 'e2e', testMatch: '**/*.spec.{js,ts}',