Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/c4-boundary-relation-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'mermaid': patch
---

fix(c4): allow boundaries as relationship endpoints

A `Rel` (or `BiRel`/`Rel_*`) that referenced a boundary alias threw `references an unknown
shape`, because endpoint lookup only searched shapes and not boundaries. Boundary aliases now
resolve as relationship endpoints. Fixes #4864.
16 changes: 16 additions & 0 deletions cypress/integration/rendering/c4/c4.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,20 @@ C4Context
{}
);
});
it('C4.9 should render a relationship whose endpoint is a boundary', () => {
imgSnapshotTest(
`
C4Context
title Relationship to a boundary

Container_Boundary(banking, "Internet Banking") {
Container(spa, "SPA", "JavaScript", "Front-end")
}
System_Ext(email, "Email System", "External mail")

Rel(banking, email, "Forwards alerts")
`,
{}
);
});
});
6 changes: 5 additions & 1 deletion packages/mermaid/src/diagrams/c4/c4Db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,11 @@ export const getC4ShapeArray = function (parentBoundary?: string | null) {
}
};
export const getC4Shape = function (alias: string) {
return c4ShapeArray.find((personOrSystem) => personOrSystem.alias === alias);
// Relations may target a boundary as well as a shape, so fall back to boundaries (#4864).
return (
c4ShapeArray.find((personOrSystem) => personOrSystem.alias === alias) ??
boundaries.find((boundary) => boundary.alias === alias)
);
};
export const getC4ShapeKeys = function (parentBoundary?: string | null) {
return Object.keys(getC4ShapeArray(parentBoundary));
Expand Down
36 changes: 30 additions & 6 deletions packages/mermaid/src/diagrams/c4/c4Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ import type { Diagram } from '../../Diagram.js';
import type { C4DiagramConfig } from '../../config.type.js';
import type { SVG } from '../../diagram-api/types.js';
import type { TextDimensionConfig } from '../../types.js';
import type { C4Boundary, C4DrawConfig, C4Font, C4Rel, C4Shape, C4Text } from './c4Types.js';
import type {
C4Boundary,
C4DrawConfig,
C4Element,
C4Font,
C4Point,
C4Rel,
C4Shape,
C4Text,
} from './c4Types.js';
import { shapes } from '../../rendering-util/rendering-elements/shapes.js';
import { buildC4Node } from './c4ShapeAdapter.js';
import intersect from '../../rendering-util/rendering-elements/intersect/index.js';

type C4DB = typeof c4Db;

Expand Down Expand Up @@ -241,6 +251,18 @@ export const drawBoundary = function (diagram: SVG, boundary: C4Boundary, bounds
boundary.y = starty;
boundary.width = bounds.data.stopx! - startx;
boundary.height = bounds.data.stopy! - starty;
// A boundary can be a relationship endpoint, and its box is a plain rectangle.
// intersect.rect measures from the centre, while the C4 grid stores the top-left corner.
boundary.intersect = (point: C4Point) =>
intersect.rect(
{
x: boundary.x + boundary.width / 2,
y: boundary.y + boundary.height / 2,
width: boundary.width,
height: boundary.height,
},
point
);

boundary.label.y = conf.c4ShapeMargin - 35;

Expand Down Expand Up @@ -340,17 +362,17 @@ class Point {
/*
* Get the intersection of the line between the center point of a rectangle and a point outside the rectangle.
*/
const getIntersectPoint = function (fromNode: C4Shape, endPoint: Point): Point | null {
const getIntersectPoint = function (fromNode: C4Element, endPoint: Point): Point | null {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!fromNode.intersect) {
throw new Error(
`C4 shape "${fromNode.alias}" has no intersect function. Please report this to https://github.com/mermaid-js/mermaid/issues`
`C4 element "${fromNode.alias}" has no intersect function. Please report this to https://github.com/mermaid-js/mermaid/issues`
);
}
const { x, y } = fromNode.intersect(endPoint);
return new Point(x, y);
};

const getIntersectPoints = function (fromNode: C4Shape, endNode: C4Shape) {
const getIntersectPoints = function (fromNode: C4Element, endNode: C4Element) {
const endIntersectPoint = { x: 0, y: 0 };
endIntersectPoint.x = endNode.x + endNode.width / 2;
endIntersectPoint.y = endNode.y + endNode.height / 2;
Expand All @@ -365,7 +387,7 @@ const getIntersectPoints = function (fromNode: C4Shape, endNode: C4Shape) {
export const drawRels = function (
diagram: SVG,
rels: C4Rel[],
getC4ShapeObj: (alias: string) => C4Shape | undefined,
getC4ShapeObj: (alias: string) => C4Element | undefined,
diagObj: Diagram,
diagramId: string
) {
Expand Down Expand Up @@ -394,7 +416,9 @@ export const drawRels = function (
const fromNode = getC4ShapeObj(rel.from);
const endNode = getC4ShapeObj(rel.to);
if (!fromNode || !endNode) {
throw new Error(`C4 rel "${rel.from}" -> "${rel.to}" references an unknown shape`);
throw new Error(
`C4 rel "${rel.from}" -> "${rel.to}" references an unknown shape or boundary`
);
}
const points = getIntersectPoints(fromNode, endNode);
if (!points.startPoint || !points.endPoint) {
Expand Down
5 changes: 5 additions & 0 deletions packages/mermaid/src/diagrams/c4/c4Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ export interface C4Boundary {
width: number;
height: number;
image: C4Image;
/* Should be set by the renderer once the boundary's box is known */
intersect?: (point: C4Point) => C4Point;
}

/** A relationship endpoint: shapes and boundaries share the geometry used to draw relations. */
export type C4Element = C4Shape | C4Boundary;

export interface C4Rel {
/** The parser may set additional keys via `{ key: value }` shaped arguments. */
[key: string]: unknown;
Expand Down
22 changes: 22 additions & 0 deletions packages/mermaid/src/diagrams/c4/parser/c4Boundary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,26 @@ System(SystemAA, "Internet Banking System")
},
});
});

// Regression test for #4864: relations may target a boundary, so getC4Shape must resolve
// boundary aliases as well as shape aliases (the renderer uses it to find rel endpoints).
it('should resolve a boundary alias as a relation endpoint', function () {
c4.parser.parse(`C4Context
${macroName}(b1, "BankBoundary") {
System(SystemAA, "Internet Banking System")
}
Rel(b1, SystemAA, "Contains")`);

const yy = c4.parser.yy;
// Identity rather than a matching alias: this proves the endpoint resolved from the
// boundaries array, not from a shape that happens to carry the same alias.
const boundary = yy.getBoundaries().find((b: { alias: string }) => b.alias === 'b1');
expect(boundary).toBeDefined();
expect(yy.getC4Shape('b1')).toBe(boundary);
expect(yy.getC4Shape('SystemAA')).toMatchObject({
alias: 'SystemAA',
typeC4Shape: { text: 'system' },
});
expect(yy.getC4Shape('missing')).toBeUndefined();
});
});
Loading