From 05df8c234c06db963876101fb4ad435f0983ba38 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 18 Sep 2026 09:06:39 +0200 Subject: [PATCH] feat(c4): render relationships through the unified edge renderer The second slice of the in-place migration, after elements moved in #7842. Relationships now go through the shared edge renderer: dashed lines, arrowheads, labels and the offsets and colours `UpdateRelStyle` sets, instead of the legacy renderer's own line drawing. Two things the shared path needs that the legacy one did not: A label is built for the effective `htmlLabels` setting, because `createText`'s non-markdown branch emits tags as ordinary words - with HTML labels off, markup rendered as literal characters. Unit tests cover both forms and a hostile string. A colour from `UpdateRelStyle` is checked with `CSS.supports('color', ...)` before it reaches a style string, so a value carrying a `;` cannot corrupt the declaration it lands in. --- .changeset/c4-unified-edges.md | 7 + .../mermaid/src/diagrams/c4/c4Renderer.ts | 133 ++++++++++++------ .../src/diagrams/c4/c4ShapeAdapter.spec.ts | 52 +++++++ .../mermaid/src/diagrams/c4/c4ShapeAdapter.ts | 42 ++++++ packages/mermaid/src/diagrams/c4/styles.js | 24 ++++ 5 files changed, 216 insertions(+), 42 deletions(-) create mode 100644 .changeset/c4-unified-edges.md create mode 100644 packages/mermaid/src/diagrams/c4/c4ShapeAdapter.spec.ts diff --git a/.changeset/c4-unified-edges.md b/.changeset/c4-unified-edges.md new file mode 100644 index 00000000000..0b7d50e0753 --- /dev/null +++ b/.changeset/c4-unified-edges.md @@ -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. diff --git a/packages/mermaid/src/diagrams/c4/c4Renderer.ts b/packages/mermaid/src/diagrams/c4/c4Renderer.ts index 4f2cf82a0f7..0a28136bb59 100644 --- a/packages/mermaid/src/diagrams/c4/c4Renderer.ts +++ b/packages/mermaid/src/diagrams/c4/c4Renderer.ts @@ -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'; @@ -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; @@ -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, @@ -372,19 +367,37 @@ 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, @@ -392,26 +405,29 @@ export const drawRels = function ( 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); @@ -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( @@ -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; diff --git a/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.spec.ts b/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.spec.ts new file mode 100644 index 00000000000..a2d58e46633 --- /dev/null +++ b/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.spec.ts @@ -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( + 'Uses
[HTTPS]
to read accounts' + ); + }); + + it('omits the parts a relationship does not have', () => { + expect(buildEdgeLabel(rel('Uses'), true)).toBe('Uses'); + expect(buildEdgeLabel(rel('Uses', undefined, 'why'), true)).toBe( + 'Uses
why' + ); + }); + + // With htmlLabels off the label is rendered as SVG text, where markup would show up as + // literal characters rather than as emphasis. `
` 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
[HTTPS]
to read accounts' + ); + }); + + it('escapes user text in an HTML label, and leaves it alone in a plain one', () => { + const hostile = rel(' & "x"'); + expect(buildEdgeLabel(hostile, true)).toBe( + '<script>alert(1)</script> & "x"' + ); + // The plain form is set as text content, so the characters need no escaping here. + expect(buildEdgeLabel(hostile, false)).toBe(' & "x"'); + }); + + it('builds an HTML label by default', () => { + expect(buildEdgeLabel(rel('Uses'))).toBe('Uses'); + }); + + 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('[HTTPS]'); + expect(buildEdgeLabel({ label: { text: '' } }, true)).toBe(''); + }); +}); diff --git a/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts b/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts index 7f861ff9a08..6bb6b1971ed 100644 --- a/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts +++ b/packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts @@ -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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +/** + * A C4 relationship label: name, then optional `[technology]` and description, one per + * line. `
` 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('
'); + } + // 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(`${escapeHtml(name)}`); + } + if (techn) { + lines.push(`[${escapeHtml(techn)}]`); + } + if (descr) { + lines.push(`${escapeHtml(descr)}`); + } + return lines.join('
'); +}; + /** The C4 element types, internal and external, that carry per-type config. */ export const C4_ELEMENT_TYPES = ( [ diff --git a/packages/mermaid/src/diagrams/c4/styles.js b/packages/mermaid/src/diagrams/c4/styles.js index 76c39a0df5d..66465bf6013 100644 --- a/packages/mermaid/src/diagrams/c4/styles.js +++ b/packages/mermaid/src/diagrams/c4/styles.js @@ -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;