Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/rounded-edge-curves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'mermaid': minor
---

fix: replace smooth curve edges with rounded right-angle edges

The default flowchart edge curve changes from `basis` (smooth splines) to `rounded` (right-angle segments with rounded corners). This fixes ELK layout edges that were curving instead of routing at right angles (#7213) and applies consistently across all diagram types using the shared rendering pipeline.

To restore the previous smooth curve behavior, set `flowchart.curve: 'basis'` in your config.
29 changes: 29 additions & 0 deletions cypress/integration/rendering/flowchart-elk.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1106,4 +1106,33 @@ describe('Title and arrow styling #4813', () => {
expect(edges[3].getAttribute('class')).to.contain('edge-thickness-invisible');
});
});

it('7213: should render ELK edges with right angles not curves', () => {
imgSnapshotTest(
`---
config:
layout: elk
---
flowchart LR
subgraph G1
N00
N11
N12
N13
end
subgraph G2
N21
N22
end
N00 --- N01 & N02 & N03 & N04 & N05
N00 --- N11 & N12 & N13 & N22
N11 --- N22
N11 --- N22
N11 --- N22
N11 --- N22
N11 --- N22
`,
{}
);
});
});
13 changes: 13 additions & 0 deletions cypress/integration/rendering/flowchart-v2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,19 @@ end
});
});

it('7213: should render edges with rounded curve (right angles with rounded corners)', () => {
imgSnapshotTest(
`flowchart TD
A[Start] --> B{Decision}
B -->|Yes| C[Process 1]
B -->|No| D[Process 2]
C --> E[End]
D --> E
`,
{ flowchart: { curve: 'rounded' } }
);
});

it('6617: Per Link Curve Styling using edge Ids', () => {
imgSnapshotTest(
`flowchart TD
Expand Down
3 changes: 2 additions & 1 deletion packages/mermaid/src/config.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ export interface FlowchartDiagramConfig extends BaseDiagramConfig {
| 'natural'
| 'step'
| 'stepAfter'
| 'stepBefore';
| 'stepBefore'
| 'rounded';
/**
* Represents the padding between the labels and the shape
*
Expand Down
110 changes: 19 additions & 91 deletions packages/mermaid/src/rendering-util/rendering-elements/edges.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ import createLabel from './createLabel.js';
import { addEdgeMarkers } from './edgeMarker.ts';
import { isLabelStyle, styles2String } from './shapes/handDrawnShapeStyles.js';

/**
* Resolve the effective curve type for an edge.
* If edge.curve is a string (e.g. 'rounded', 'linear'), use it directly.
* Otherwise (undefined, null, or a D3 CurveFactory function), fall back to config.
* @param {*} edgeCurve - The edge.curve value (string, function, or undefined/null)
* @returns {string|undefined} - The resolved curve type string
*/
export const resolveEdgeCurveType = (edgeCurve) => {
return typeof edgeCurve === 'string' ? edgeCurve : getConfig()?.flowchart?.curve;
};

export const edgeLabels = new Map();
export const terminalLabels = new Map();

Expand Down Expand Up @@ -421,92 +432,6 @@ const cutPathAtIntersect = (_points, boundaryNode) => {
return points;
};

function extractCornerPoints(points) {
const cornerPoints = [];
const cornerPointPositions = [];
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
if (
prev.x === curr.x &&
curr.y === next.y &&
Math.abs(curr.x - next.x) > 5 &&
Math.abs(curr.y - prev.y) > 5
) {
cornerPoints.push(curr);
cornerPointPositions.push(i);
} else if (
prev.y === curr.y &&
curr.x === next.x &&
Math.abs(curr.x - prev.x) > 5 &&
Math.abs(curr.y - next.y) > 5
) {
cornerPoints.push(curr);
cornerPointPositions.push(i);
}
}
return { cornerPoints, cornerPointPositions };
}

const findAdjacentPoint = function (pointA, pointB, distance) {
const xDiff = pointB.x - pointA.x;
const yDiff = pointB.y - pointA.y;
const length = Math.sqrt(xDiff * xDiff + yDiff * yDiff);
const ratio = distance / length;
return { x: pointB.x - ratio * xDiff, y: pointB.y - ratio * yDiff };
};

const fixCorners = function (lineData) {
const { cornerPointPositions } = extractCornerPoints(lineData);
const newLineData = [];
for (let i = 0; i < lineData.length; i++) {
if (cornerPointPositions.includes(i)) {
const prevPoint = lineData[i - 1];
const nextPoint = lineData[i + 1];
const cornerPoint = lineData[i];

const newPrevPoint = findAdjacentPoint(prevPoint, cornerPoint, 5);
const newNextPoint = findAdjacentPoint(nextPoint, cornerPoint, 5);

const xDiff = newNextPoint.x - newPrevPoint.x;
const yDiff = newNextPoint.y - newPrevPoint.y;
newLineData.push(newPrevPoint);

const a = Math.sqrt(2) * 2;
let newCornerPoint = { x: cornerPoint.x, y: cornerPoint.y };
if (Math.abs(nextPoint.x - prevPoint.x) > 10 && Math.abs(nextPoint.y - prevPoint.y) >= 10) {
log.debug(
'Corner point fixing',
Math.abs(nextPoint.x - prevPoint.x),
Math.abs(nextPoint.y - prevPoint.y)
);
const r = 5;
if (cornerPoint.x === newPrevPoint.x) {
newCornerPoint = {
x: xDiff < 0 ? newPrevPoint.x - r + a : newPrevPoint.x + r - a,
y: yDiff < 0 ? newPrevPoint.y - a : newPrevPoint.y + a,
};
} else {
newCornerPoint = {
x: xDiff < 0 ? newPrevPoint.x - a : newPrevPoint.x + a,
y: yDiff < 0 ? newPrevPoint.y - r + a : newPrevPoint.y + r - a,
};
}
} else {
log.debug(
'Corner point skipping fixing',
Math.abs(nextPoint.x - prevPoint.x),
Math.abs(nextPoint.y - prevPoint.y)
);
}
newLineData.push(newCornerPoint, newNextPoint);
} else {
newLineData.push(lineData[i]);
}
}
return newLineData;
};
const generateDashArray = (len, oValueS, oValueE) => {
const middleLength = len - oValueS - oValueE;
const dashLength = 2; // Length of each dash
Expand Down Expand Up @@ -582,10 +507,10 @@ export const insertEdge = function (
}

let lineData = points.filter((p) => !Number.isNaN(p.y));
lineData = fixCorners(lineData);
let curve = curveBasis;
curve = curveLinear;
switch (edge.curve) {
let curve = curveLinear;
// Resolve curve type: use edge.curve if it's a string, otherwise fall back to config default
const edgeCurveType = resolveEdgeCurveType(edge.curve);
switch (edgeCurveType) {
case 'linear':
curve = curveLinear;
break;
Expand Down Expand Up @@ -622,6 +547,9 @@ export const insertEdge = function (
case 'stepBefore':
curve = curveStepBefore;
break;
case 'rounded':
curve = curveLinear;
break;
default:
curve = curveBasis;
}
Expand Down Expand Up @@ -662,7 +590,7 @@ export const insertEdge = function (
}
let svgPath;
let linePath =
edge.curve === 'rounded'
edgeCurveType === 'rounded'
? generateRoundedPath(applyMarkerOffsetsToPoints(lineData, edge), 5)
: lineFunction(lineData);
const edgeStyles = Array.isArray(edge.style) ? edge.style : [edge.style];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';

// Mock getConfig to control flowchart.curve
vi.mock('../../diagram-api/diagramAPI.js', () => ({
getConfig: vi.fn(() => ({
flowchart: { curve: 'rounded' },
handDrawnSeed: 0,
})),
}));

import { resolveEdgeCurveType } from './edges.js';

describe('resolveEdgeCurveType', () => {
it('should return edge.curve when it is a string', () => {
expect(resolveEdgeCurveType('linear')).toBe('linear');
expect(resolveEdgeCurveType('basis')).toBe('basis');
expect(resolveEdgeCurveType('rounded')).toBe('rounded');
expect(resolveEdgeCurveType('cardinal')).toBe('cardinal');
});

it('should fall back to config flowchart.curve when edge.curve is undefined', () => {
// When edge.curve is undefined, should resolve from config (which is mocked as 'rounded')
expect(resolveEdgeCurveType(undefined)).toBe('rounded');
});

it('should fall back to config flowchart.curve when edge.curve is not a string (D3 function)', () => {
// Class diagrams and other non-flowchart types may pass a D3 CurveFactory function
// eslint-disable-next-line @typescript-eslint/no-empty-function
const fakeCurveFactory = () => {};
expect(resolveEdgeCurveType(fakeCurveFactory)).toBe('rounded');
});

it('should fall back to config flowchart.curve when edge.curve is null', () => {
expect(resolveEdgeCurveType(null)).toBe('rounded');
});
});
3 changes: 2 additions & 1 deletion packages/mermaid/src/schemas/config.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2155,8 +2155,9 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
'step',
'stepAfter',
'stepBefore',
'rounded',
]
default: 'basis'
default: 'rounded'
padding:
description: |
Represents the padding between the labels and the shape
Expand Down
Loading