Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: edge case causing extreme height in Architecture Diagrams #6064

Merged
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
5 changes: 5 additions & 0 deletions .changeset/angry-bags-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: architecture diagrams no longer grow to extreme heights due to conflicting alignments
52 changes: 52 additions & 0 deletions cypress/integration/rendering/architecture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,58 @@ describe.skip('architecture diagram', () => {
`
);
});

it('should render an architecture diagram with a resonable height', () => {
imgSnapshotTest(
`architecture-beta
group federated(cloud)[Federated Environment]
service server1(server)[System] in federated
service edge(server)[Edge Device] in federated
server1:R -- L:edge

group on_prem(cloud)[Hub]
service firewall(server)[Firewall Device] in on_prem
service server(server)[Server] in on_prem
firewall:R -- L:server

service db1(database)[db1] in on_prem
service db2(database)[db2] in on_prem
service db3(database)[db3] in on_prem
service db4(database)[db4] in on_prem
service db5(database)[db5] in on_prem
service db6(database)[db6] in on_prem

junction mid in on_prem
server:B -- T:mid

junction 1Leftofmid in on_prem
1Leftofmid:R -- L:mid
1Leftofmid:B -- T:db1

junction 2Leftofmid in on_prem
2Leftofmid:R -- L:1Leftofmid
2Leftofmid:B -- T:db2

junction 3Leftofmid in on_prem
3Leftofmid:R -- L:2Leftofmid
3Leftofmid:B -- T:db3

junction 1RightOfMid in on_prem
mid:R -- L:1RightOfMid
1RightOfMid:B -- T:db4

junction 2RightOfMid in on_prem
1RightOfMid:R -- L:2RightOfMid
2RightOfMid:B -- T:db5

junction 3RightOfMid in on_prem
2RightOfMid:R -- L:3RightOfMid
3RightOfMid:B -- T:db6

edge:R -- L:firewall
`
);
});
});

// Skipped as the layout is not deterministic, and causes issues in E2E tests.
Expand Down
25 changes: 24 additions & 1 deletion packages/mermaid/src/diagrams/architecture/architectureDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
setDiagramTitle,
} from '../common/commonDb.js';
import type {
ArchitectureAlignment,
ArchitectureDB,
ArchitectureDirectionPair,
ArchitectureDirectionPairMap,
Expand All @@ -25,6 +26,7 @@ import type {
ArchitectureState,
} from './architectureTypes.js';
import {
getArchitectureDirectionAlignment,
getArchitectureDirectionPair,
isArchitectureDirection,
isArchitectureJunction,
Expand Down Expand Up @@ -211,19 +213,38 @@ const addEdge = function ({
const getEdges = (): ArchitectureEdge[] => state.records.edges;

/**
* Returns the current diagram's adjacency list & spatial map.
* Returns the current diagram's adjacency list, spatial map, & group alignments.
* If they have not been created, run the algorithms to generate them.
* @returns
*/
const getDataStructures = () => {
if (state.records.dataStructures === undefined) {
// Tracks how groups are aligned with one another. Generated while creating the adj list
const groupAlignments: Record<
string,
Record<string, Exclude<ArchitectureAlignment, 'bend'>>
> = {};

// Create an adjacency list of the diagram to perform BFS on
// Outer reduce applied on all services
// Inner reduce applied on the edges for a service
const adjList = Object.entries(state.records.nodes).reduce<
Record<string, ArchitectureDirectionPairMap>
>((prevOuter, [id, service]) => {
prevOuter[id] = service.edges.reduce<ArchitectureDirectionPairMap>((prevInner, edge) => {
// track the direction groups connect to one another
const lhsGroupId = getNode(edge.lhsId)?.in;
const rhsGroupId = getNode(edge.rhsId)?.in;
if (lhsGroupId && rhsGroupId && lhsGroupId !== rhsGroupId) {
const alignment = getArchitectureDirectionAlignment(edge.lhsDir, edge.rhsDir);
if (alignment !== 'bend') {
groupAlignments[lhsGroupId] ??= {};
groupAlignments[lhsGroupId][rhsGroupId] = alignment;
groupAlignments[rhsGroupId] ??= {};
groupAlignments[rhsGroupId][lhsGroupId] = alignment;
}
}

if (edge.lhsId === id) {
// source is LHS
const pair = getArchitectureDirectionPair(edge.lhsDir, edge.rhsDir);
Expand All @@ -245,6 +266,7 @@ const getDataStructures = () => {
// Configuration for the initial pass of BFS
const firstId = Object.keys(adjList)[0];
const visited = { [firstId]: 1 };
// If a key is present in this object, it has not been visited
const notVisited = Object.keys(adjList).reduce(
(prev, id) => (id === firstId ? prev : { ...prev, [id]: 1 }),
{} as Record<string, number>
Expand Down Expand Up @@ -283,6 +305,7 @@ const getDataStructures = () => {
state.records.dataStructures = {
adjList,
spatialMaps,
groupAlignments,
};
}
return state.records.dataStructures;
Expand Down
102 changes: 85 additions & 17 deletions packages/mermaid/src/diagrams/architecture/architectureRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import { getConfigField } from './architectureDb.js';
import { architectureIcons } from './architectureIcons.js';
import type {
ArchitectureAlignment,
ArchitectureDataStructures,
ArchitectureGroupAlignments,
ArchitectureJunction,
ArchitectureSpatialMap,
EdgeSingular,
Expand Down Expand Up @@ -149,25 +151,91 @@ function addEdges(edges: ArchitectureEdge[], cy: cytoscape.Core) {
});
}

function getAlignments(spatialMaps: ArchitectureSpatialMap[]): fcose.FcoseAlignmentConstraint {
function getAlignments(
db: ArchitectureDB,
spatialMaps: ArchitectureSpatialMap[],
groupAlignments: ArchitectureGroupAlignments
): fcose.FcoseAlignmentConstraint {
/**
* Flattens the alignment object so nodes in different groups will be in the same alignment array IFF their groups don't connect in a conflicting alignment
*
* i.e., two groups which connect horizontally should not have nodes with vertical alignments to one another
*
* See: #5952
*
* @param alignmentObj - alignment object with the outer key being the row/col # and the inner key being the group name mapped to the nodes on that axis in the group
* @param alignmentDir - alignment direction
* @returns flattened alignment object with an arbitrary key mapping to nodes in the same row/col
*/
const flattenAlignments = (
alignmentObj: Record<number, Record<string, string[]>>,
alignmentDir: ArchitectureAlignment
): Record<string, string[]> => {
return Object.entries(alignmentObj).reduce(
(prev, [dir, alignments]) => {
// prev is the mapping of x/y coordinate to an array of the nodes in that row/column
let cnt = 0;
const arr = Object.entries(alignments); // [group name, array of nodes within the group on axis dir]
if (arr.length === 1) {
// If only one group exists in the row/column, we don't need to do anything else
prev[dir] = arr[0][1];
return prev;
}
for (let i = 0; i < arr.length - 1; i++) {
for (let j = i + 1; j < arr.length; j++) {
const [aGroupId, aNodeIds] = arr[i];
const [bGroupId, bNodeIds] = arr[j];
const alignment = groupAlignments[aGroupId]?.[bGroupId]; // Get how the two groups are intended to align (undefined if they aren't)

if (alignment === alignmentDir) {
// If the intended alignment between the two groups is the same as the alignment we are parsing
prev[dir] ??= [];
prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds]; // add the node ids of both groups to the axis array in prev
} else if (aGroupId === 'default' || bGroupId === 'default') {
// If either of the groups are in the default space (not in a group), use the same behavior as above
prev[dir] ??= [];
prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds];
} else {
// Otherwise, the nodes in the two groups are not intended to align
const keyA = `${dir}-${cnt++}`;
prev[keyA] = aNodeIds;
const keyB = `${dir}-${cnt++}`;
prev[keyB] = bNodeIds;
}
}
}

return prev;
},
{} as Record<string, string[]>
);
};

const alignments = spatialMaps.map((spatialMap) => {
const horizontalAlignments: Record<number, string[]> = {};
const verticalAlignments: Record<number, string[]> = {};
const horizontalAlignments: Record<number, Record<string, string[]>> = {};
const verticalAlignments: Record<number, Record<string, string[]>> = {};

// Group service ids in an object with their x and y coordinate as the key
Object.entries(spatialMap).forEach(([id, [x, y]]) => {
if (!horizontalAlignments[y]) {
horizontalAlignments[y] = [];
}
if (!verticalAlignments[x]) {
verticalAlignments[x] = [];
}
horizontalAlignments[y].push(id);
verticalAlignments[x].push(id);
const nodeGroup = db.getNode(id)?.in ?? 'default';

horizontalAlignments[y] ??= {};
horizontalAlignments[y][nodeGroup] ??= [];
horizontalAlignments[y][nodeGroup].push(id);

verticalAlignments[x] ??= {};
verticalAlignments[x][nodeGroup] ??= [];
verticalAlignments[x][nodeGroup].push(id);
});

// Merge the values of each object into a list if the inner list has at least 2 elements
return {
horiz: Object.values(horizontalAlignments).filter((arr) => arr.length > 1),
vert: Object.values(verticalAlignments).filter((arr) => arr.length > 1),
horiz: Object.values(flattenAlignments(horizontalAlignments, 'horizontal')).filter(
(arr) => arr.length > 1
),
vert: Object.values(flattenAlignments(verticalAlignments, 'vertical')).filter(
(arr) => arr.length > 1
),
};
});

Expand Down Expand Up @@ -244,7 +312,8 @@ function layoutArchitecture(
junctions: ArchitectureJunction[],
groups: ArchitectureGroup[],
edges: ArchitectureEdge[],
{ spatialMaps }: ArchitectureDataStructures
db: ArchitectureDB,
{ spatialMaps, groupAlignments }: ArchitectureDataStructures
): Promise<cytoscape.Core> {
return new Promise((resolve) => {
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
Expand Down Expand Up @@ -318,9 +387,8 @@ function layoutArchitecture(
addServices(services, cy);
addJunctions(junctions, cy);
addEdges(edges, cy);

// Use the spatial map to create alignment arrays for fcose
const alignmentConstraint = getAlignments(spatialMaps);
const alignmentConstraint = getAlignments(db, spatialMaps, groupAlignments);

// Create the relative constraints for fcose by using an inverse of the spatial map and performing BFS on it
const relativePlacementConstraint = getRelativeConstraints(spatialMaps);
Expand Down Expand Up @@ -454,7 +522,7 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
await drawServices(db, servicesElem, services);
drawJunctions(db, servicesElem, junctions);

const cy = await layoutArchitecture(services, junctions, groups, edges, ds);
const cy = await layoutArchitecture(services, junctions, groups, edges, db, ds);

await drawEdges(edgesElem, cy);
await drawGroups(groupElem, cy);
Expand Down
32 changes: 32 additions & 0 deletions packages/mermaid/src/diagrams/architecture/architectureTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type cytoscape from 'cytoscape';
| Architecture Diagram Types |
\*=======================================*/

export type ArchitectureAlignment = 'vertical' | 'horizontal' | 'bend';

export type ArchitectureDirection = 'L' | 'R' | 'T' | 'B';
export type ArchitectureDirectionX = Extract<ArchitectureDirection, 'L' | 'R'>;
export type ArchitectureDirectionY = Extract<ArchitectureDirection, 'T' | 'B'>;
Expand Down Expand Up @@ -170,6 +172,18 @@ export const getArchitectureDirectionXYFactors = function (
}
};

export const getArchitectureDirectionAlignment = function (
a: ArchitectureDirection,
b: ArchitectureDirection
): ArchitectureAlignment {
if (isArchitectureDirectionXY(a, b)) {
return 'bend';
} else if (isArchitectureDirectionX(a)) {
return 'horizontal';
}
return 'vertical';
};

export interface ArchitectureStyleOptions {
archEdgeColor: string;
archEdgeArrowColor: string;
Expand Down Expand Up @@ -249,9 +263,27 @@ export interface ArchitectureDB extends DiagramDB {

export type ArchitectureAdjacencyList = Record<string, ArchitectureDirectionPairMap>;
export type ArchitectureSpatialMap = Record<string, number[]>;

/**
* Maps the direction that groups connect from.
*
* **Outer key**: ID of group A
*
* **Inner key**: ID of group B
*
* **Value**: 'vertical' or 'horizontal'
*
* Note: tmp[groupA][groupB] == tmp[groupB][groupA]
*/
export type ArchitectureGroupAlignments = Record<
string,
Record<string, Exclude<ArchitectureAlignment, 'bend'>>
>;

export interface ArchitectureDataStructures {
adjList: ArchitectureAdjacencyList;
spatialMaps: ArchitectureSpatialMap[];
groupAlignments: ArchitectureGroupAlignments;
}

export interface ArchitectureState extends Record<string, unknown> {
Expand Down
Loading