Skip to content
7 changes: 7 additions & 0 deletions .changeset/feat-architecture-align-directive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'mermaid': minor
---

feat: add `align row|column {ids…}` directive to architecture-beta diagrams so authors can declare horizontal or vertical alignment of services explicitly, fixing same-port sibling overlap (e.g. three databases all connecting to one node) and enabling clean grid layouts when paired with column directives.

**Note:** this introduces three new reserved keywords in architecture-beta β€” `align`, `row`, and `column`. Any existing diagram using one of these as an exact id (e.g. `service row(database)[Row]`) will now fail to parse and must be renamed. Identifiers that merely contain these as a prefix (e.g. `rowspan`, `columnar`) keep working via langium's longer-alt tokenizer.
56 changes: 56 additions & 0 deletions cypress/integration/rendering/architecture/architecture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,62 @@ describe('architecture - fcose layout knobs', () => {
});
});

describe('architecture - align directive', () => {
it('should stack three same-port databases in a column without overlap', () => {
imgSnapshotTest(
`architecture-beta
group api(cloud)[API]
service db1(database)[DB1] in api
service db2(database)[DB2] in api
service db3(database)[DB3] in api
service mcp(server)[MCP] in api
db1:R --> L:mcp
db2:R --> L:mcp
db3:R --> L:mcp
align column db1 db2 db3
`
);
});

it('should align siblings in a row when their edges feed a common downstream node', () => {
imgSnapshotTest(
`architecture-beta
service src1(server)[Source 1]
service src2(server)[Source 2]
service src3(server)[Source 3]
service proc(server)[Processor]
src1:B --> T:proc
src2:B --> T:proc
src3:B --> T:proc
align row src1 src2 src3
`
);
});

it('should render a grid via combined row + column alignments', () => {
imgSnapshotTest(
`architecture-beta
group tier1(cloud)[Tier 1]
service a1(server)[A1] in tier1
service a2(server)[A2] in tier1
service a3(server)[A3] in tier1
group tier2(database)[Tier 2]
service b1(database)[B1] in tier2
service b2(database)[B2] in tier2
service b3(database)[B3] in tier2
a1:B --> T:b1
a2:B --> T:b2
a3:B --> T:b3
align row a1 a2 a3
align row b1 b2 b3
align column a1 b1
align column a2 b2
align column a3 b3
`
);
});
});

describe('architecture - external', () => {
it('should allow adding external icons', () => {
urlSnapshotTest('/architecture-external.html');
Expand Down
148 changes: 148 additions & 0 deletions docs/syntax/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,154 @@ creates an edge going out of `groupOne`, adjacent to `server`, and into `groupTw

It's important to note that `groupId`s cannot be used for specifying edges and the `{group}` modifier can only be used for services within a group.

### Aligning siblings (v\<MERMAID_RELEASE_VERSION>+)

When several services share similar edge topology (for example, three databases all connecting `R --> L:mcp`), the layout heuristic may collapse them onto the same coordinate so that two render on top of each other. The `align` directive declares that a set of services share a row (same y) or a column (same x), and forces them to spread along that axis.

```
align row {idA} {idB} {idC} ...
align column {idA} {idB} ...
```

Members must already be declared as services or junctions, and at least two members are required. Each `align` directive lives on its own line.

Pick the axis based on how the listed members are connected:

- Use **`align column`** when the members connect to a common downstream node _via the same horizontal port pair_ (e.g. all use `R --> L:mcp`). They naturally form a vertical stack to one side, with parallel arrows reaching the downstream node.
- Use **`align row`** when the members connect to a common downstream node _via the same vertical port pair_ (e.g. all use `B --> T:proc`). They naturally form a horizontal row above the downstream node.

Three databases all feeding `mcp` via right-to-left edges β†’ stack them in a column:

```mermaid-example
architecture-beta
group api(cloud)[API]
service db1(database)[DB1] in api
service db2(database)[DB2] in api
service db3(database)[DB3] in api
service mcp(server)[MCP] in api
db1:R --> L:mcp
db2:R --> L:mcp
db3:R --> L:mcp
align column db1 db2 db3
```

```mermaid
architecture-beta
group api(cloud)[API]
service db1(database)[DB1] in api
service db2(database)[DB2] in api
service db3(database)[DB3] in api
service mcp(server)[MCP] in api
db1:R --> L:mcp
db2:R --> L:mcp
db3:R --> L:mcp
align column db1 db2 db3
```

Three sources all feeding `proc` via top-to-bottom edges β†’ arrange them in a row:

```mermaid-example
architecture-beta
service src1(server)[Source 1]
service src2(server)[Source 2]
service src3(server)[Source 3]
service proc(server)[Processor]
src1:B --> T:proc
src2:B --> T:proc
src3:B --> T:proc
align row src1 src2 src3
```

```mermaid
architecture-beta
service src1(server)[Source 1]
service src2(server)[Source 2]
service src3(server)[Source 3]
service proc(server)[Processor]
src1:B --> T:proc
src2:B --> T:proc
src3:B --> T:proc
align row src1 src2 src3
```

The order of members in the `align` directive determines their order along the axis. The gap between aligned members is controlled by `idealEdgeLengthMultiplier`.

> **Note:** the declared order must not contradict the directions of edges between the listed members. For example, if the diagram contains `a:L --> R:b` (which places `a` to the right of `b`), then `align row a b` will conflict with that edge direction and the layout engine will fail to render. Use `align row b a` instead, or remove the conflicting edge.

#### Grid layouts (combining `row` and `column`)

`align row` only pins the y-coordinate of its members. To produce a clean grid where columns also align across tiers, pair each `align row` with one or more `align column` directives. The columns can span as many rows as you like β€” chain every node that should share an x-coordinate, even across groups.

```mermaid-example
architecture-beta
group sources(cloud)[Sources]
service src_a(server)[Source A] in sources
service src_b(server)[Source B] in sources
service src_c(server)[Source C] in sources

group storage(database)[Storage]
service db_one(database)[DB One] in storage
service db_two(database)[DB Two] in storage
service db_three(database)[DB Three] in storage

group output(disk)[Output]
service brief(disk)[Brief] in output
service analyst(server)[Analyst] in output
service delivery(cloud)[Delivery] in output

src_a:B --> T:db_one
src_b:B --> T:db_two
src_c:B --> T:db_three
db_two:B --> T:brief
brief:R --> L:analyst
analyst:R --> L:delivery

align row src_a src_b src_c
align row db_one db_two db_three
align row brief analyst delivery

align column src_a db_one
align column src_b db_two brief
align column src_c db_three
```

```mermaid
architecture-beta
group sources(cloud)[Sources]
service src_a(server)[Source A] in sources
service src_b(server)[Source B] in sources
service src_c(server)[Source C] in sources

group storage(database)[Storage]
service db_one(database)[DB One] in storage
service db_two(database)[DB Two] in storage
service db_three(database)[DB Three] in storage

group output(disk)[Output]
service brief(disk)[Brief] in output
service analyst(server)[Analyst] in output
service delivery(cloud)[Delivery] in output

src_a:B --> T:db_one
src_b:B --> T:db_two
src_c:B --> T:db_three
db_two:B --> T:brief
brief:R --> L:analyst
analyst:R --> L:delivery

align row src_a src_b src_c
align row db_one db_two db_three
align row brief analyst delivery

align column src_a db_one
align column src_b db_two brief
align column src_c db_three
```

The result is three left-to-right tiers stacked vertically with a straight spine through the middle column. Edges between aligned nodes render as straight horizontal or vertical lines; cross-axis edges (e.g. `db_one:R --> T:hub`) get a single 90Β° elbow.

> **Tip:** if a long single-word label is too wide to fit on one line at small `iconSize` values, increase `iconSize` (or use a shorter title) to keep it on one line.

### Junctions

Junctions are a special type of node which acts as a potential 4-way split between edges.
Expand Down
78 changes: 78 additions & 0 deletions packages/mermaid/src/diagrams/architecture/architecture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,84 @@ describe('architecture diagrams', () => {
});
});

describe('align directive', () => {
it('should parse a row alignment and expose it via getLayoutHints', async () => {
const str = `architecture-beta
group api(cloud)[API]
service db1(database)[DB1] in api
service db2(database)[DB2] in api
service db3(database)[DB3] in api
align row db1 db2 db3`;
await expect(parser.parse(str)).resolves.not.toThrow();
const hints = db.getLayoutHints();
expect(hints).toHaveLength(1);
expect(hints[0].direction).toBe('row');
expect(hints[0].members).toEqual(['db1', 'db2', 'db3']);
});

it('should parse a column alignment', async () => {
const str = `architecture-beta
service a(server)[A]
service b(server)[B]
align column a b`;
await expect(parser.parse(str)).resolves.not.toThrow();
const hints = db.getLayoutHints();
expect(hints).toHaveLength(1);
expect(hints[0].direction).toBe('column');
expect(hints[0].members).toEqual(['a', 'b']);
});

it('should reject an align directive whose member is not a service or junction', async () => {
const str = `architecture-beta
service a(server)[A]
service b(server)[B]
align row a b ghost`;
await expect(parser.parse(str)).rejects.toThrow(/ghost/);
});

it('should reject an align directive that lists the same member twice', async () => {
const str = `architecture-beta
service a(server)[A]
align row a a`;
await expect(parser.parse(str)).rejects.toThrow(/more than once/);
});

it('should reject an align directive with fewer than two members at the DB level', () => {
// The langium grammar requires `(members+=ID)+` so the parser path already
// enforces β‰₯2 members. This guards the DB API for any non-grammar callers
// (programmatic construction, future renderers, etc.).
expect(() => db.addLayoutHint({ direction: 'row', members: [] })).toThrow(
/at least two members/
);
expect(() => db.addLayoutHint({ direction: 'column', members: ['only_one'] })).toThrow(
/at least two members/
);
});

it('should not collide with services whose id starts with row or column', async () => {
const str = `architecture-beta
service rowspan(server)[Rowspan]
service columnar(server)[Columnar]
align row rowspan columnar`;
await expect(parser.parse(str)).resolves.not.toThrow();
expect(db.getServices().map((s) => s.id)).toEqual(['rowspan', 'columnar']);
expect(db.getLayoutHints()[0].members).toEqual(['rowspan', 'columnar']);
});

it.each(['align', 'row', 'column'])(
'should reject a service whose id is the exact reserved keyword %s',
async (keyword) => {
// The directive introduces `align`, `row`, and `column` as reserved
// keywords in architecture-beta. An exact-match id (no prefix/suffix)
// must be rejected at parse time so authors get a clear error rather
// than a downstream layout failure.
const str = `architecture-beta
service ${keyword}(database)[Service Using Reserved Word]`;
await expect(parser.parse(str)).rejects.toThrow();
}
);
});

describe('addJunction validation', () => {
it('should throw if junction id is already in use by a service', () => {
db.addGroup({ id: 'g1', title: 'Group' });
Expand Down
28 changes: 28 additions & 0 deletions packages/mermaid/src/diagrams/architecture/architectureDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
ArchitectureEdge,
ArchitectureGroup,
ArchitectureJunction,
ArchitectureLayoutHint,
ArchitectureNode,
ArchitectureService,
ArchitectureSpatialMap,
Expand All @@ -40,6 +41,7 @@ export class ArchitectureDB implements DiagramDB {
private nodes: Record<string, ArchitectureNode> = {};
private groups: Record<string, ArchitectureGroup> = {};
private edges: ArchitectureEdge[] = [];
private layoutHints: ArchitectureLayoutHint[] = [];
private registeredIds: Record<string, 'node' | 'group'> = {};
private dataStructures?: ArchitectureState['dataStructures'];
private elements: Record<string, D3Element> = {};
Expand All @@ -61,6 +63,7 @@ export class ArchitectureDB implements DiagramDB {
this.nodes = {};
this.groups = {};
this.edges = [];
this.layoutHints = [];
this.registeredIds = {};
this.dataStructures = undefined;
this.elements = {};
Expand Down Expand Up @@ -254,6 +257,31 @@ export class ArchitectureDB implements DiagramDB {
return this.edges;
}

public addLayoutHint(hint: ArchitectureLayoutHint): void {
if (hint.members.length < 2) {
throw new Error(
`An align directive requires at least two members; got ${hint.members.length}`
);
}
const seen = new Set<string>();
hint.members.forEach((id) => {
if (this.registeredIds[id] !== 'node') {
throw new Error(
`align ${hint.direction} references [${id}], which is not a service or junction`
);
}
if (seen.has(id)) {
throw new Error(`align ${hint.direction} lists [${id}] more than once`);
}
seen.add(id);
});
this.layoutHints.push(hint);
}

public getLayoutHints(): ArchitectureLayoutHint[] {
return this.layoutHints;
}

/**
* Returns the current diagram's adjacency list, spatial map, & group alignments.
* If they have not been created, run the algorithms to generate them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const populateDb = (ast: Architecture, db: ArchitectureDB) => {
ast.junctions.map((service) => db.addJunction({ ...service, type: 'junction' }));
// @ts-ignore TODO our parser guarantees the type is L/R/T/B and not string. How to change to union type?
ast.edges.map((edge) => db.addEdge(edge));
ast.alignments?.map((alignment) =>
db.addLayoutHint({ direction: alignment.direction, members: [...alignment.members] })
);
};

export const parser: ParserDefinition = {
Expand Down
Loading
Loading