mirror of
				https://github.com/mermaid-js/mermaid.git
				synced 2025-10-31 19:04:16 +01:00 
			
		
		
		
	Merge pull request #6064 from NicolasNewman/5952/architecture-extreme-height
Fix: edge case causing extreme height in Architecture Diagrams
This commit is contained in:
		
							
								
								
									
										5
									
								
								.changeset/angry-bags-brake.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.changeset/angry-bags-brake.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| --- | ||||
| 'mermaid': patch | ||||
| --- | ||||
|  | ||||
| fix: architecture diagrams no longer grow to extreme heights due to conflicting alignments | ||||
| @@ -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. | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { | ||||
|   setDiagramTitle, | ||||
| } from '../common/commonDb.js'; | ||||
| import type { | ||||
|   ArchitectureAlignment, | ||||
|   ArchitectureDB, | ||||
|   ArchitectureDirectionPair, | ||||
|   ArchitectureDirectionPairMap, | ||||
| @@ -25,6 +26,7 @@ import type { | ||||
|   ArchitectureState, | ||||
| } from './architectureTypes.js'; | ||||
| import { | ||||
|   getArchitectureDirectionAlignment, | ||||
|   getArchitectureDirectionPair, | ||||
|   isArchitectureDirection, | ||||
|   isArchitectureJunction, | ||||
| @@ -211,12 +213,18 @@ 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 | ||||
| @@ -224,6 +232,19 @@ const getDataStructures = () => { | ||||
|       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); | ||||
| @@ -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> | ||||
| @@ -283,6 +305,7 @@ const getDataStructures = () => { | ||||
|     state.records.dataStructures = { | ||||
|       adjList, | ||||
|       spatialMaps, | ||||
|       groupAlignments, | ||||
|     }; | ||||
|   } | ||||
|   return state.records.dataStructures; | ||||
|   | ||||
| @@ -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, | ||||
| @@ -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 | ||||
|       ), | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
| @@ -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'); | ||||
| @@ -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); | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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'>; | ||||
| @@ -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; | ||||
| @@ -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> { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Sidharth Vinod
					Sidharth Vinod