Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/c4-unified-edges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'mermaid': minor
---

feat(c4): render C4 relationships through the unified edge rendering

C4 relationships are drawn by mermaid's shared edge renderer instead of bespoke SVG: a dashed line with an arrowhead (both ends for `BiRel`) and a label carrying the relationship name, its optional `[technology]` and its description. `UpdateRelStyle`'s `$offsetX`/`$offsetY` still nudge the label and `$textColor`/`$lineColor` still recolour it. Element geometry is unchanged - the line runs between the two shapes' boundary intersection points, so it stays in the gap between boxes.
133 changes: 91 additions & 42 deletions packages/mermaid/src/diagrams/c4/c4Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { parser } from './parser/c4Diagram.jison';
import common from '../common/common.js';
import c4Db from './c4Db.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { getEffectiveHtmlLabels } from '../../config.js';
import { getRequiredConfig } from '../../diagram-api/requiredConfig.js';
import assignWithDepth from '../../assignWithDepth.js';
import { wrapLabel, calculateTextWidth, calculateTextHeight } from '../../utils.js';
Expand All @@ -25,7 +26,9 @@ import type {
C4Text,
} from './c4Types.js';
import { shapes } from '../../rendering-util/rendering-elements/shapes.js';
import { buildC4Node } from './c4ShapeAdapter.js';
import { buildC4Node, buildEdgeLabel } from './c4ShapeAdapter.js';
import { insertEdgeLabel, insertEdge } from '../../rendering-util/rendering-elements/edges.js';
import insertMarkers from '../../rendering-util/rendering-elements/markers.js';
import intersect from '../../rendering-util/rendering-elements/intersect/index.js';

type C4DB = typeof c4Db;
Expand Down Expand Up @@ -193,14 +196,6 @@ const boundaryFont = (cnf: C4DrawConfig): C4Font => {
};
};

const messageFont = (cnf: C4DrawConfig): C4Font => {
return {
fontFamily: cnf.messageFontFamily,
fontSize: cnf.messageFontSize as number,
fontWeight: cnf.messageFontWeight,
};
};

function calcC4ShapeTextWH(
textType: 'label' | 'type' | 'techn' | 'descr',
c4Shape: C4Shape | C4Boundary | C4Rel,
Expand Down Expand Up @@ -372,46 +367,67 @@ const getIntersectPoint = function (fromNode: C4Element, endPoint: Point): Point
return new Point(x, y);
};

/**
* An `UpdateRelStyle` colour, accepted only if it is a colour on its own. The value is
* interpolated into a CSS declaration, so one carrying `;` or a `url(...)` could append
* further declarations; `CSS.supports` rejects those, and anything it cannot judge (no
* CSS API, as in jsdom) falls back to a conservative pattern match.
*/
const asColor = (value: unknown): string | undefined => {
if (typeof value !== 'string' || value === '') {
return undefined;
}
const accepted =
typeof globalThis.CSS?.supports === 'function'
? globalThis.CSS.supports('color', value)
: /^(#[\da-f]{3,8}|[a-z]+|rgba?\([\d\s%,./]+\)|hsla?\([\d\s%,./deg]+\))$/i.test(value);
return accepted ? value : undefined;
};

// Where the line between two shapes meets each one's boundary. Each endpoint comes
// from that shape's own `intersect`, captured from the unified shape that drew it,
// so an arrow stops at the person or cylinder silhouette rather than at its bounding
// box. Aiming each endpoint at the other shape's centre keeps the segment on the line
// joining the two centres.
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;
const startPoint = getIntersectPoint(fromNode, endIntersectPoint);

endIntersectPoint.x = fromNode.x + fromNode.width / 2;
endIntersectPoint.y = fromNode.y + fromNode.height / 2;
const endPoint = getIntersectPoint(endNode, endIntersectPoint);
const fromCenter = new Point(fromNode.x + fromNode.width / 2, fromNode.y + fromNode.height / 2);
const endCenter = new Point(endNode.x + endNode.width / 2, endNode.y + endNode.height / 2);
const startPoint = getIntersectPoint(fromNode, endCenter);
const endPoint = getIntersectPoint(endNode, fromCenter);
return { startPoint: startPoint, endPoint: endPoint };
};

export const drawRels = function (
export const drawRels = async function (
diagram: SVG,
rels: C4Rel[],
getC4ShapeObj: (alias: string) => C4Element | undefined,
diagObj: Diagram,
diagramId: string
) {
const diagramType = (diagObj.db as C4DB).getC4Type();
const config = getConfig();
const look = config.look ?? 'classic';
// Emphasis tags only render as emphasis in an HTML label; the plain form drops them.
const useHtmlLabels = getEffectiveHtmlLabels(config);

// Relationships are drawn through the unified edge renderer: a dashed line with an
// arrowhead and an HTML label, matching c4model.com. Edges (and their labels) are drawn
// ON TOP of the shapes - as the legacy C4 renderer did - so the connection line is never
// hidden behind a box. The legacy grid still supplies the geometry (a straight line
// between the two shapes' boundary intersection points), which keeps the line in the gaps.
const edgePaths = diagram.append('g').attr('class', 'edgePaths');
const edgeLabels = diagram.append('g').attr('class', 'edgeLabels');
insertMarkers(diagram, ['point'], 'c4', diagramId);
// The intersection points are already clipped to the shape boundaries, so insertEdge does
// not need to clip again (skipIntersect); the node stubs are therefore unused.
const nodeStub = { intersect: (point: Point) => point };

let i = 0;
for (const rel of rels) {
i = i + 1;
const relTextWrap = rel.wrap && conf.wrap;
const relConf = messageFont(conf);
if (diagramType === 'C4Dynamic') {
rel.label.text = i + ': ' + rel.label.text;
}
let textLimitWidth = calculateTextWidth(rel.label.text, relConf as TextDimensionConfig);
calcC4ShapeTextWH('label', rel, relTextWrap, relConf, textLimitWidth);

if (rel.techn && rel.techn.text !== '') {
textLimitWidth = calculateTextWidth(rel.techn.text, relConf as TextDimensionConfig);
calcC4ShapeTextWH('techn', rel, relTextWrap, relConf, textLimitWidth);
}

if (rel.descr && rel.descr.text !== '') {
textLimitWidth = calculateTextWidth(rel.descr.text, relConf as TextDimensionConfig);
calcC4ShapeTextWH('descr', rel, relTextWrap, relConf, textLimitWidth);
}

const fromNode = getC4ShapeObj(rel.from);
const endNode = getC4ShapeObj(rel.to);
Expand All @@ -426,10 +442,49 @@ export const drawRels = function (
`Could not calculate intersection points for rel "${rel.from}" -> "${rel.to}"`
);
}
rel.startPoint = points.startPoint;
rel.endPoint = points.endPoint;

// Honour UpdateRelStyle: $offsetX/$offsetY nudge the label, $textColor/$lineColor recolour it.
// The db may store the offsets as strings (named attributes land in the textColor/lineColor
// slots, which aren't parseInt'd) or as numbers (positional), so coerce defensively.
const toOffset = (value: unknown) => {
const n = Number(value);
return Number.isNaN(n) ? 0 : n;
};
const offsetX = toOffset(rel.offsetX);
const offsetY = toOffset(rel.offsetY);
const lineColor = asColor(rel.lineColor);
const textColor = asColor(rel.textColor);
const labelX = (points.startPoint.x + points.endPoint.x) / 2 + offsetX;
const labelY = (points.startPoint.y + points.endPoint.y) / 2 + offsetY;

const edge = {
id: `${diagramId}_rel${i}`,
label: buildEdgeLabel(rel, useHtmlLabels),
labelType: 'string',
classes: 'c4-rel',
arrowTypeEnd: 'arrow_point',
arrowTypeStart: rel.type === 'birel' ? 'arrow_point' : undefined,
thickness: 'normal',
pattern: 'dashed',
curve: 'linear',
look,
points: [points.startPoint, points.endPoint],
style: lineColor ? [`stroke:${lineColor}`] : undefined,
cssStyles: textColor ? [`color:${textColor}`] : undefined,
};

if (edge.label) {
await insertEdgeLabel(edgeLabels, edge);
// Position the label at the line midpoint + the UpdateRelStyle offset. We set the
// transform directly rather than via positionEdgeLabel, which discards edge.x/y (and
// so the offset) whenever insertEdge happens to report an updated path.
const labelGroup = edgeLabels.node()?.lastElementChild;
if (labelGroup) {
labelGroup.setAttribute('transform', `translate(${labelX}, ${labelY})`);
}
}
insertEdge(edgePaths, edge, new Map(), 'c4', nodeStub, nodeStub, diagramId, true);
}
svgDraw.drawRels(diagram, rels, conf, diagramId);
};

async function drawInsideBoundary(
Expand Down Expand Up @@ -604,13 +659,7 @@ export const draw = async function (_text: string, id: string, _version: string,
// break;
// }

// The arrow head definition is attached to the svg once
svgDraw.insertArrowHead(diagram, id);
svgDraw.insertArrowEnd(diagram, id);
svgDraw.insertArrowCrossHead(diagram, id);
svgDraw.insertArrowFilledHead(diagram, id);

drawRels(diagram, db.getRels(), db.getC4Shape, diagObj, id);
await drawRels(diagram, db.getRels(), db.getC4Shape, diagObj, id);

screenBounds.data.stopx = globalBoundaryMaxX;
screenBounds.data.stopy = globalBoundaryMaxY;
Expand Down
52 changes: 52 additions & 0 deletions packages/mermaid/src/diagrams/c4/c4ShapeAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { buildEdgeLabel } from './c4ShapeAdapter.js';

const rel = (label: string, techn?: string, descr?: string) => ({
label: { text: label },
techn: techn === undefined ? undefined : { text: techn },
descr: descr === undefined ? undefined : { text: descr },
});

describe('buildEdgeLabel', () => {
it('emphasises the name and the technology in an HTML label', () => {
expect(buildEdgeLabel(rel('Uses', 'HTTPS', 'to read accounts'), true)).toBe(
'<b>Uses</b><br/><small><i>[HTTPS]</i></small><br/><small>to read accounts</small>'
);
});

it('omits the parts a relationship does not have', () => {
expect(buildEdgeLabel(rel('Uses'), true)).toBe('<b>Uses</b>');
expect(buildEdgeLabel(rel('Uses', undefined, 'why'), true)).toBe(
'<b>Uses</b><br/><small>why</small>'
);
});

// With htmlLabels off the label is rendered as SVG text, where markup would show up as
// literal characters rather than as emphasis. `<br/>` survives either way: it is a line
// break in an HTML label and a line delimiter in a plain one.
it('carries the same words without markup when HTML labels are off', () => {
expect(buildEdgeLabel(rel('Uses', 'HTTPS', 'to read accounts'), false)).toBe(
'Uses<br/>[HTTPS]<br/>to read accounts'
);
});

it('escapes user text in an HTML label, and leaves it alone in a plain one', () => {
const hostile = rel('<script>alert(1)</script> & "x"');
expect(buildEdgeLabel(hostile, true)).toBe(
'<b>&lt;script&gt;alert(1)&lt;/script&gt; &amp; &quot;x&quot;</b>'
);
// The plain form is set as text content, so the characters need no escaping here.
expect(buildEdgeLabel(hostile, false)).toBe('<script>alert(1)</script> & "x"');
});

it('builds an HTML label by default', () => {
expect(buildEdgeLabel(rel('Uses'))).toBe('<b>Uses</b>');
});

it('does not shift the parts up when the relationship has no name', () => {
// `Rel(a, b, "", "HTTPS")` is accepted. Reading a filtered list back by position printed
// the technology as the name, and `undefined` as the technology.
const label = buildEdgeLabel({ label: { text: '' }, techn: { text: 'HTTPS' } }, true);
expect(label).toBe('<small><i>[HTTPS]</i></small>');
expect(buildEdgeLabel({ label: { text: '' } }, true)).toBe('');
});
});
42 changes: 42 additions & 0 deletions packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,48 @@ const stereotypeText = (shape: C4ShapeLike): string => {
return shape.techn?.text ? `[${stereotype}: ${shape.techn.text}]` : `[${stereotype}]`;
};

const escapeHtml = (txt: string): string =>
txt
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');

/**
* A C4 relationship label: name, then optional `[technology]` and description, one per
* line. `<br/>` separates the lines either way, since it is a line break in an HTML
* label and a line delimiter in a plain SVG one. The emphasis tags only render as
* emphasis in an HTML label; with `htmlLabels` off they would show as literal text, so
* the plain form carries the same words without them.
*/
export const buildEdgeLabel = (
rel: { label: C4Text; techn?: C4Text; descr?: C4Text },
useHtmlLabels = true
): string => {
const name = rel.label.text;
const techn = rel.techn?.text;
const descr = rel.descr?.text;
if (!useHtmlLabels) {
return [name, techn && `[${techn}]`, descr]
.filter((part): part is string => Boolean(part))
.join('<br/>');
}
// Each line is built from its own part: reading a filtered list back by position shifted
// every later part up whenever an earlier one was empty.
const lines: string[] = [];
if (name) {
lines.push(`<b>${escapeHtml(name)}</b>`);
}
if (techn) {
lines.push(`<small><i>[${escapeHtml(techn)}]</i></small>`);
}
if (descr) {
lines.push(`<small>${escapeHtml(descr)}</small>`);
}
return lines.join('<br/>');
};

/** The C4 element types, internal and external, that carry per-type config. */
export const C4_ELEMENT_TYPES = (
[
Expand Down
24 changes: 24 additions & 0 deletions packages/mermaid/src/diagrams/c4/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ ${elementFontStyles()}
.c4-shape line {
stroke-width: 2px;
}
/* Relationships: dashed lines with an arrowhead, as on c4model.com. */
.edgePaths .path,
path.c4-rel {
stroke: ${options.lineColor ?? '#666666'};
fill: none;
stroke-width: 1.5px;
stroke-dasharray: 6 4;
}
.edgePaths .marker {
fill: ${options.lineColor ?? '#666666'};
stroke: ${options.lineColor ?? '#666666'};
}
/* Relationship labels: smaller, on an opaque background so they stay legible over lines. */
.edgeLabel {
font-size: 0.85em;
background-color: ${options.background ?? '#ffffff'};
}
.edgeLabel .label foreignObject {
overflow: visible;
}
.edgeLabel rect {
fill: ${options.background ?? '#ffffff'};
opacity: 1;
}
`;

export default getStyles;
Loading