diff --git a/.changeset/gold-spiders-join.md b/.changeset/gold-spiders-join.md new file mode 100644 index 000000000..56bccc244 --- /dev/null +++ b/.changeset/gold-spiders-join.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: fixed connection gaps in flowchart for roundedRect, stadium and diamond shape diff --git a/cypress/integration/rendering/flowchart-v2.spec.js b/cypress/integration/rendering/flowchart-v2.spec.js index 9ad2b5604..69ff10b00 100644 --- a/cypress/integration/rendering/flowchart-v2.spec.js +++ b/cypress/integration/rendering/flowchart-v2.spec.js @@ -1113,6 +1113,37 @@ end ); }); }); + describe('Flowchart Node Shape Rendering', () => { + it('should render a stadium-shaped node', () => { + imgSnapshotTest( + `flowchart TB + A(["Start"]) --> n1["Untitled Node"] + A --> n2["Untitled Node"] + `, + {} + ); + }); + it('should render a diamond-shaped node using shape config', () => { + imgSnapshotTest( + `flowchart BT + n2["Untitled Node"] --> n1["Diamond"] + n1@{ shape: diam} + `, + {} + ); + }); + it('should render a rounded rectangle and a normal rectangle', () => { + imgSnapshotTest( + `flowchart BT + n2["Untitled Node"] --> n1["Rounded Rectangle"] + n3["Untitled Node"] --> n1 + n1@{ shape: rounded} + n3@{ shape: rect} + `, + {} + ); + }); + }); it('6617: Per Link Curve Styling using edge Ids', () => { imgSnapshotTest( diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts index 52a4397a2..e73074268 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts @@ -1,9 +1,8 @@ -import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import { labelHelper, updateNodeBounds, getNodeClasses, createPathFromPoints } from './util.js'; import intersect from '../intersect/index.js'; import type { Node } from '../../types.js'; import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; import rough from 'roughjs'; -import { insertPolygonShape } from './insertPolygonShape.js'; import type { D3Selection } from '../../../types.js'; export const createHexagonPathD = ( @@ -29,42 +28,50 @@ export async function hexagon(parent: D3Selection< node.labelStyle = labelStyles; const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); - const f = 4; - const h = bbox.height + node.padding; - const m = h / f; - const w = bbox.width + 2 * m + node.padding; - const points = [ - { x: m, y: 0 }, - { x: w - m, y: 0 }, - { x: w, y: -h / 2 }, - { x: w - m, y: -h }, - { x: m, y: -h }, - { x: 0, y: -h / 2 }, - ]; - - let polygon: D3Selection | Awaited>; + const h = bbox.height + (node.padding ?? 0); + const w = bbox.width + (node.padding ?? 0) * 2.5; const { cssStyles } = node; + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); - if (node.look === 'handDrawn') { - // @ts-expect-error -- Passing a D3.Selection seems to work for some reason - const rc = rough.svg(shapeSvg); - const options = userNodeOverrides(node, {}); - const pathData = createHexagonPathD(0, 0, w, h, m); - const roughNode = rc.path(pathData, options); - - polygon = shapeSvg - .insert(() => roughNode, ':first-child') - .attr('transform', `translate(${-w / 2}, ${h / 2})`); - - if (cssStyles) { - polygon.attr('style', cssStyles); - } - } else { - polygon = insertPolygonShape(shapeSvg, w, h, points); + if (node.look !== 'handDrawn') { + options.roughness = 0; + options.fillStyle = 'solid'; } - if (nodeStyles) { - polygon.attr('style', nodeStyles); + let halfWidth = w / 2; + const m = halfWidth / 6; // Margin for label + halfWidth = halfWidth + m; // Adjusted half width for hexagon + + const halfHeight = h / 2; + + const fixedLength = halfHeight / 2; + const deducedWidth = halfWidth - fixedLength; + + const points = [ + { x: -deducedWidth, y: -halfHeight }, + { x: 0, y: -halfHeight }, + { x: deducedWidth, y: -halfHeight }, + { x: halfWidth, y: 0 }, + { x: deducedWidth, y: halfHeight }, + { x: 0, y: halfHeight }, + { x: -deducedWidth, y: halfHeight }, + { x: -halfWidth, y: 0 }, + ]; + + const pathData = createPathFromPoints(points); + const shapeNode = rc.path(pathData, options); + + const polygon = shapeSvg.insert(() => shapeNode, ':first-child'); + polygon.attr('class', 'basic label-container'); + + if (cssStyles && node.look !== 'handDrawn') { + polygon.selectChildren('path').attr('style', cssStyles); + } + + if (nodeStyles && node.look !== 'handDrawn') { + polygon.selectChildren('path').attr('style', nodeStyles); } node.width = w; 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 eef958169..24c811b85 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts @@ -25,6 +25,7 @@ export async function question(parent: D3Selection const w = bbox.width + node.padding; const h = bbox.height + node.padding; const s = w + h; + const adjustment = 0.5; const points = [ { x: s / 2, y: 0 }, @@ -45,13 +46,14 @@ export async function question(parent: D3Selection polygon = shapeSvg .insert(() => roughNode, ':first-child') - .attr('transform', `translate(${-s / 2}, ${s / 2})`); + .attr('transform', `translate(${-s / 2 + adjustment}, ${s / 2})`); if (cssStyles) { polygon.attr('style', cssStyles); } } else { polygon = insertPolygonShape(shapeSvg, s, s, points); + polygon.attr('transform', `translate(${-s / 2 + adjustment}, ${s / 2})`); } if (nodeStyles) { diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts index 134bcdb6c..40d71429c 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts @@ -1,18 +1,160 @@ -import type { Node, RectOptions } from '../../types.js'; +import { labelHelper, updateNodeBounds, getNodeClasses, createPathFromPoints } from './util.js'; +import intersect from '../intersect/index.js'; +import type { Node } from '../../types.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import rough from 'roughjs'; import type { D3Selection } from '../../../types.js'; -import { drawRect } from './drawRect.js'; + +/** + * Generates evenly spaced points along an elliptical arc connecting two points. + * + * @param x1 - x-coordinate of the start point of the arc + * @param y1 - y-coordinate of the start point of the arc + * @param x2 - x-coordinate of the end point of the arc + * @param y2 - y-coordinate of the end point of the arc + * @param rx - horizontal radius of the ellipse + * @param ry - vertical radius of the ellipse + * @param clockwise - direction of the arc; true for clockwise, false for counterclockwise + * @returns Array of points `{ x, y }` along the elliptical arc + * + * @throws Error if the given radii are too small to draw an arc between the points + */ +export function generateArcPoints( + x1: number, + y1: number, + x2: number, + y2: number, + rx: number, + ry: number, + clockwise: boolean +) { + const numPoints = 20; + // Calculate midpoint + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + + // Calculate the angle of the line connecting the points + const angle = Math.atan2(y2 - y1, x2 - x1); + + // Calculate transformed coordinates for the ellipse + const dx = (x2 - x1) / 2; + const dy = (y2 - y1) / 2; + + // Scale to unit circle + const transformedX = dx / rx; + const transformedY = dy / ry; + + // Calculate the distance between points on the unit circle + const distance = Math.sqrt(transformedX ** 2 + transformedY ** 2); + + // Check if the ellipse can be drawn with the given radii + if (distance > 1) { + throw new Error('The given radii are too small to create an arc between the points.'); + } + + // Calculate the distance from the midpoint to the center of the ellipse + const scaledCenterDistance = Math.sqrt(1 - distance ** 2); + + // Calculate the center of the ellipse + const centerX = midX + scaledCenterDistance * ry * Math.sin(angle) * (clockwise ? -1 : 1); + const centerY = midY - scaledCenterDistance * rx * Math.cos(angle) * (clockwise ? -1 : 1); + + // Calculate the start and end angles on the ellipse + const startAngle = Math.atan2((y1 - centerY) / ry, (x1 - centerX) / rx); + const endAngle = Math.atan2((y2 - centerY) / ry, (x2 - centerX) / rx); + + // Adjust angles for clockwise/counterclockwise + let angleRange = endAngle - startAngle; + if (clockwise && angleRange < 0) { + angleRange += 2 * Math.PI; + } + if (!clockwise && angleRange > 0) { + angleRange -= 2 * Math.PI; + } + + // Generate points + const points = []; + for (let i = 0; i < numPoints; i++) { + const t = i / (numPoints - 1); + const angle = startAngle + t * angleRange; + const x = centerX + rx * Math.cos(angle); + const y = centerY + ry * Math.sin(angle); + points.push({ x, y }); + } + + return points; +} export async function roundedRect( parent: D3Selection, node: Node ) { - const options = { - rx: 5, - ry: 5, - classes: '', - labelPaddingX: (node?.padding || 0) * 1, - labelPaddingY: (node?.padding || 0) * 1, - } as RectOptions; + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); - return drawRect(parent, node, options); + const labelPaddingX = node?.padding ?? 0; + const labelPaddingY = node?.padding ?? 0; + + const w = (node?.width ? node?.width : bbox.width) + labelPaddingX * 2; + const h = (node?.height ? node?.height : bbox.height) + labelPaddingY * 2; + const radius = 5; + const taper = 5; // Taper width for the rounded corners + const { cssStyles } = node; + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + + if (node.look !== 'handDrawn') { + options.roughness = 0; + options.fillStyle = 'solid'; + } + + const points = [ + // Top edge (left to right) + { x: -w / 2 + taper, y: -h / 2 }, // Top-left corner start (1) + { x: w / 2 - taper, y: -h / 2 }, // Top-right corner start (2) + + ...generateArcPoints(w / 2 - taper, -h / 2, w / 2, -h / 2 + taper, radius, radius, true), // Top-left arc (2 to 3) + + // Right edge (top to bottom) + { x: w / 2, y: -h / 2 + taper }, // Top-right taper point (3) + { x: w / 2, y: h / 2 - taper }, // Bottom-right taper point (4) + + ...generateArcPoints(w / 2, h / 2 - taper, w / 2 - taper, h / 2, radius, radius, true), // Top-left arc (4 to 5) + + // Bottom edge (right to left) + { x: w / 2 - taper, y: h / 2 }, // Bottom-right corner start (5) + { x: -w / 2 + taper, y: h / 2 }, // Bottom-left corner start (6) + + ...generateArcPoints(-w / 2 + taper, h / 2, -w / 2, h / 2 - taper, radius, radius, true), // Top-left arc (4 to 5) + + // Left edge (bottom to top) + { x: -w / 2, y: h / 2 - taper }, // Bottom-left taper point (7) + { x: -w / 2, y: -h / 2 + taper }, // Top-left taper point (8) + ...generateArcPoints(-w / 2, -h / 2 + taper, -w / 2 + taper, -h / 2, radius, radius, true), // Top-left arc (4 to 5) + ]; + + const pathData = createPathFromPoints(points); + const shapeNode = rc.path(pathData, options); + + const polygon = shapeSvg.insert(() => shapeNode, ':first-child'); + polygon.attr('class', 'basic label-container outer-path'); + + if (cssStyles && node.look !== 'handDrawn') { + polygon.selectChildren('path').attr('style', cssStyles); + } + + if (nodeStyles && node.look !== 'handDrawn') { + polygon.selectChildren('path').attr('style', nodeStyles); + } + + updateNodeBounds(node, polygon); + + node.intersect = function (point) { + const pos = intersect.polygon(node, points, point); + return pos; + }; + + return shapeSvg; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts index 1b93aa1b3..117176b19 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts @@ -1,11 +1,15 @@ -import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import { + labelHelper, + updateNodeBounds, + getNodeClasses, + generateCirclePoints, + createPathFromPoints, +} from './util.js'; import intersect from '../intersect/index.js'; import type { Node } from '../../types.js'; import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; import rough from 'roughjs'; -import { createRoundedRectPathD } from './roundedRectPath.js'; import type { D3Selection } from '../../../types.js'; -import { handleUndefinedAttr } from '../../../utils.js'; export const createStadiumPathD = ( x: number, @@ -60,36 +64,44 @@ export async function stadium(parent: D3Selection< const h = bbox.height + node.padding; const w = bbox.width + h / 4 + node.padding; - let rect; + const radius = h / 2; const { cssStyles } = node; - if (node.look === 'handDrawn') { - // @ts-expect-error -- Passing a D3.Selection seems to work for some reason - const rc = rough.svg(shapeSvg); - const options = userNodeOverrides(node, {}); + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); - const pathData = createRoundedRectPathD(-w / 2, -h / 2, w, h, h / 2); - const roughNode = rc.path(pathData, options); - - rect = shapeSvg.insert(() => roughNode, ':first-child'); - rect.attr('class', 'basic label-container').attr('style', handleUndefinedAttr(cssStyles)); - } else { - rect = shapeSvg.insert('rect', ':first-child'); - - rect - .attr('class', 'basic label-container') - .attr('style', nodeStyles) - .attr('rx', h / 2) - .attr('ry', h / 2) - .attr('x', -w / 2) - .attr('y', -h / 2) - .attr('width', w) - .attr('height', h); + if (node.look !== 'handDrawn') { + options.roughness = 0; + options.fillStyle = 'solid'; } - updateNodeBounds(node, rect); + const points = [ + { x: -w / 2 + radius, y: -h / 2 }, + { x: w / 2 - radius, y: -h / 2 }, + ...generateCirclePoints(-w / 2 + radius, 0, radius, 50, 90, 270), + { x: w / 2 - radius, y: h / 2 }, + ...generateCirclePoints(w / 2 - radius, 0, radius, 50, 270, 450), + ]; + + const pathData = createPathFromPoints(points); + const shapeNode = rc.path(pathData, options); + + const polygon = shapeSvg.insert(() => shapeNode, ':first-child'); + polygon.attr('class', 'basic label-container outer-path'); + + if (cssStyles && node.look !== 'handDrawn') { + polygon.selectChildren('path').attr('style', cssStyles); + } + + if (nodeStyles && node.look !== 'handDrawn') { + polygon.selectChildren('path').attr('style', nodeStyles); + } + + updateNodeBounds(node, polygon); node.intersect = function (point) { - return intersect.rect(node, point); + const pos = intersect.polygon(node, points, point); + return pos; }; return shapeSvg;