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
10 changes: 10 additions & 0 deletions .changeset/sequence-neo-redux-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'mermaid': patch
---

fix: sequence diagram actors under the `neo` look and `redux` themes. Notes no longer render bold,
a `rect` section band is no longer drawn white on white, and participants follow one vertical
model: every lifeline starts and ends on a shared line, all participant glyphs are one size with
their feet on a common edge, single-line labels share one baseline in the header and in the
mirrored footer, and a multiline label grows away from the lifeline instead of moving it. The
`classic` look is unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
config:
theme: redux-color
look: neo
---
sequenceDiagram
actor U as Multi<br/>line<br/>actor
participant P as Plain
participant B@{ "type" : "boundary" } as Single
participant C@{ "type" : "control" } as Two<br/>lines
participant DB@{ "type" : "database" } as Database
U ->> P: the lifelines should share one start
P ->> DB: and one end
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
config:
theme: redux-color
look: neo
---
sequenceDiagram
actor User
participant API
participant DB@{ "type" : "database" } as Database
Note over User: A note on the actor
rect
User ->> API: request
API ->> DB: query
end
Note over API,DB: A wider note
rect rgb(200, 230, 255)
DB -->> API: rows
end
API -->> User: response
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
sequenceDiagram
actor User
participant DB@{ "type" : "database" } as Database
User ->> DB: query
DB -->> User: rows
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
sequenceDiagram
actor User
participant Plain as Participant
participant Bound@{ "type" : "boundary" } as Boundary
participant Ctrl@{ "type" : "control" } as Control
participant Ent@{ "type" : "entity" } as Entity
participant DB@{ "type" : "database" } as Database
participant Q@{ "type" : "queue" } as Queue
participant Coll@{ "type" : "collections" } as Collections
User ->> Plain: start
Plain ->> Bound: in
Bound ->> Ctrl: handle
Ctrl ->> Ent: load
Ent ->> DB: read
DB ->> Q: enqueue
Q ->> Coll: fan out
Coll -->> User: done
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
sequenceDiagram
actor User
participant API
participant DB@{ "type" : "database" } as Database
Note over User: Note over a single actor
User ->> API: request
Note right of API: Note to the right
API ->> DB: query
Note over API,DB: Note spanning two participants
DB -->> API: rows
Note left of User: Note to the left
API -->> User: response
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
sequenceDiagram
actor User
participant API
participant DB@{ "type" : "database" } as Database
rect
User ->> API: inside a bare rect, theme default fill
API ->> DB: query
end
API -->> User: outside any rect
rect rgb(200, 230, 255)
User ->> API: inside a rect with an explicit colour
end
rect
User ->> API: outer bare rect
rect rgb(255, 230, 200)
API ->> DB: nested rect with an explicit colour
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
sequenceDiagram
actor User
participant API
participant DB@{ "type" : "database" } as Database
Note over User: bold note text was fix 1
rect
User ->> API: the band behind this was invisible, fix 3
API ->> DB: query
end
Note over API,DB: a wider note
rect rgb(200, 230, 255)
DB -->> API: an explicitly coloured band always worked
end
API -->> User: the stick figure size was fix 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
sequenceDiagram
actor User
participant DB@{ "type" : "database" } as Database
loop every retry
User ->> DB: attempt
end
alt found
DB -->> User: rows
else missing
DB -->> User: empty
end
opt cache
User ->> DB: warm
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
sequenceDiagram
actor U as Multi<br/>line<br/>actor
participant P as Plain
participant B@{ "type" : "boundary" } as Single
participant C@{ "type" : "control" } as Two<br/>lines
participant DB@{ "type" : "database" } as Database
U ->> P: the lifelines should share one start
P ->> DB: and one end
232 changes: 232 additions & 0 deletions packages/mermaid/src/diagrams/sequence/actorBandModel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Pipeline-level assertions for the band model: a real `mermaid.render`, all eight participant
* shapes on one row, header and footer.
*
* These deliberately do not call `drawActor` in isolation. The regressions this model exists for
* lived *between* the pieces -- shapes measuring themselves with `getBBox()` and feeding the
* result back into `actor.height`, which the footer then consumed -- so a test that stubs the
* measurement to a constant validates its own assumption and nothing else. Here `getBBox` is
* emulated from the geometry actually emitted, and text has a realistic nonzero height, so the
* feedback paths run for real.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import mermaid from '../../mermaid.js';

const TEXT_LINE_HEIGHT = 19;

const num = (el: Element, n: string) => Number(el.getAttribute(n) ?? 0);

const applyOwnTranslate = (
el: Element,
box: { x: number; y: number; width: number; height: number }
) => {
const m = /translate\(\s*([\d.-]+)\s*[\s,]\s*([\d.-]+)\s*\)/.exec(
el.getAttribute('transform') ?? ''
);
return m ? { ...box, x: box.x + Number(m[1]), y: box.y + Number(m[2]) } : box;
};

const bboxOf = (el: Element): { x: number; y: number; width: number; height: number } => {
const tag = el.tagName.toLowerCase();
if (tag === 'circle') {
const r = num(el, 'r');
return { x: num(el, 'cx') - r, y: num(el, 'cy') - r, width: 2 * r, height: 2 * r };
}
if (tag === 'line') {
const [x1, x2, y1, y2] = [num(el, 'x1'), num(el, 'x2'), num(el, 'y1'), num(el, 'y2')];
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
};
}
if (tag === 'rect') {
return { x: num(el, 'x'), y: num(el, 'y'), width: num(el, 'width'), height: num(el, 'height') };
}
if (tag === 'path') {
const ys = [...(el.getAttribute('d') ?? '').matchAll(/([\d.-]+)[\s,]([\d.-]+)/g)]
.map((m) => Number(m[2]))
.filter(Number.isFinite);
if (!ys.length) {
return { x: 0, y: 0, width: 0, height: 0 };
}
return { x: 0, y: Math.min(...ys), width: 10, height: Math.max(...ys) - Math.min(...ys) };
}
if (tag === 'text' || tag === 'tspan') {
const lines = Math.max(1, el.querySelectorAll('tspan').length);
return {
x: num(el, 'x'),
y: num(el, 'y'),
width: (el.textContent ?? '').length * 7,
height: TEXT_LINE_HEIGHT * lines,
};
}
// Per the SVG spec, an element's getBBox excludes its own transform but includes its
// children's. The boundary shape hid a translate from three rounds of measurement because an
// earlier emulation skipped this.
const kids = [...el.children]
.map((child) => applyOwnTranslate(child, bboxOf(child)))
.filter((b) => b.width || b.height);
if (!kids.length) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const x = Math.min(...kids.map((b) => b.x));
const y = Math.min(...kids.map((b) => b.y));
return {
x,
y,
width: Math.max(...kids.map((b) => b.x + b.width)) - x,
height: Math.max(...kids.map((b) => b.y + b.height)) - y,
};
};

beforeAll(() => {
Object.defineProperty(SVGElement.prototype, 'getBBox', {
configurable: true,
value(this: SVGElement) {
return bboxOf(this);
},
});
Object.defineProperty(SVGElement.prototype, 'getComputedTextLength', {
configurable: true,
value(this: SVGElement) {
return (this.textContent ?? '').length * 7;
},
});
});

const MIXED_ROW = `sequenceDiagram
actor User
participant Plain as Plain
participant B@{ "type" : "boundary" } as Bound
participant C@{ "type" : "control" } as Ctrl
participant E@{ "type" : "entity" } as Ent
participant DB@{ "type" : "database" } as Database
participant Q@{ "type" : "queue" } as Que
participant Co@{ "type" : "collections" } as Coll
User ->> DB: query
`;

/** Shapes that hang their label below a glyph; their labels share one baseline. */
const OUTSIDE_LABELS = new Set(['User', 'Bound', 'Ctrl', 'Ent', 'Database']);

const render = async (id: string, source: string) => {
mermaid.initialize({ theme: 'redux-color', look: 'neo', startOnLoad: false });
const { svg } = await mermaid.render(id, source);
return new DOMParser().parseFromString(svg, 'image/svg+xml');
};

const lifelines = (doc: Document) =>
[...doc.querySelectorAll('line[data-et="life-line"]')].map((l) => ({
id: l.getAttribute('data-id')!,
y1: Number(l.getAttribute('y1')),
y2: Number(l.getAttribute('y2')),
}));

/** Header labels sit above the first datum, footer labels below the second. */
const splitLabels = (doc: Document, datumTop: number) => {
const header = new Map<string, number>();
const footer = new Map<string, number>();
for (const t of doc.querySelectorAll('text.actor')) {
const y = Number(t.getAttribute('y'));
(y <= datumTop ? header : footer).set(t.textContent!, y);
}
return { header, footer };
};

describe('actor band model (neo, real pipeline)', () => {
it('starts and ends every lifeline on the two datum lines', async () => {
const doc = await render('bm-datum', MIXED_ROW);
const lls = lifelines(doc);

expect(lls).toHaveLength(8);
expect(new Set(lls.map((l) => l.y1)).size).toBe(1);
expect(new Set(lls.map((l) => l.y2)).size).toBe(1);
});

it('puts every outside label on one baseline, clear of the lifeline, header and footer', async () => {
const doc = await render('bm-labels', MIXED_ROW);
const [{ y1: datumTop }] = lifelines(doc);
const { header, footer } = splitLabels(doc, datumTop);

const headerYs = [...OUTSIDE_LABELS].map((n) => header.get(n));
expect(new Set(headerYs).size).toBe(1);
// The label's ink must end above the datum: centre + half the real text height, with room.
expect((headerYs[0] ?? Infinity) + TEXT_LINE_HEIGHT / 2).toBeLessThan(datumTop);

const footerYs = [...OUTSIDE_LABELS].map((n) => footer.get(n));
expect(new Set(footerYs).size).toBe(1);
});

it('keeps a multiline label on the datum, growing away from it', async () => {
const single = await render('bm-single', MIXED_ROW);
const multi = await render(
'bm-multi',
MIXED_ROW.replace('actor User', 'actor User as First line<br/>Second line')
);

const singleLifelines = lifelines(single);
const multiLifelines = lifelines(multi);
// The row grows, but stays one datum.
expect(new Set(multiLifelines.map((l) => l.y1)).size).toBe(1);

const singleDatum = singleLifelines[0].y1;
const multiDatum = multiLifelines[0].y1;
expect(multiDatum).toBeGreaterThan(singleDatum);

// Single-line neighbours keep their distance to the datum -- the extra line grew upward past
// them, it did not push them off the shared baseline.
const singleLabels = splitLabels(single, singleDatum).header;
const multiLabels = splitLabels(multi, multiDatum).header;
expect(multiDatum - multiLabels.get('Database')!).toBe(
singleDatum - singleLabels.get('Database')!
);
});

it('renders every glyph inside the bands, transforms included', async () => {
// The assertion that finally catches what getBBox cannot: boundary and entity carried legacy
// `translate` nudges on their groups, so their local boxes matched control's while their
// rendered positions sat 21 and 6 lower. Measured here with each group's own transform
// applied, the way the browser paints it.
const doc = await render('bm-glyphs', MIXED_ROW);
const [{ y1: datumTop }] = lifelines(doc);

const glyphBottoms = new Map<string, number>();
for (const g of doc.querySelectorAll('g[data-et="participant"]')) {
const box = applyOwnTranslate(g, bboxOf(g));
const glyphs = [...g.querySelectorAll('circle, line, rect, path')].filter(
(el) => el.getAttribute('data-et') !== 'life-line'
);
if (!glyphs.length) {
continue;
}
const bottoms = glyphs.map((el) => {
const b = applyOwnTranslate(el, bboxOf(el));
return b.y + b.height + (box.y - bboxOf(g).y);
});
glyphBottoms.set(g.getAttribute('data-id')!, Math.max(...bottoms));
}

// Nothing pokes below the datum...
for (const [, bottom] of glyphBottoms) {
expect(bottom).toBeLessThanOrEqual(datumTop + 0.5);
}
// ...and the icon family sits feet-on-one-line. A reintroduced translate breaks this.
const iconBottoms = ['User', 'B', 'C', 'E'].map((id) => glyphBottoms.get(id));
expect(new Set(iconBottoms.map((b) => Math.round(b! * 10) / 10)).size).toBe(1);
});

it('reports one uniform actor height back into the pipeline', async () => {
// The old failure mode: shapes overwrote `actor.height` from their own bounding box with
// per-shape fudge terms, and the footer consumed the corrupted values. With the model, every
// consumer of heights sees the row height. The footer labels sharing a baseline (asserted
// above) is the visible consequence; this pins the datum gap between header and footer being
// identical for every shape, which fails if any shape's height drifts.
const doc = await render('bm-height', MIXED_ROW);
const lls = lifelines(doc);
const spans = new Set(lls.map((l) => l.y2 - l.y1));

expect(spans.size).toBe(1);
});
});
Loading
Loading