diff --git a/packages/mermaid/src/diagrams/architecture/architectureDb.ts b/packages/mermaid/src/diagrams/architecture/architectureDb.ts index e08e71c36..8792308cc 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureDb.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureDb.ts @@ -5,9 +5,11 @@ import type { ArchitectureGroup, ArchitectureDirection, ArchitectureLine, + ArchitectureDirectionPairMap, + ArchitectureDirectionPair, } from './architectureTypes.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; -import { isArchitectureDirection } from './architectureTypes.js'; +import { getArchitectureDirectionPair, isArchitectureDirection, shiftPositionByArchitectureDirectionPair } from './architectureTypes.js'; import { setAccTitle, getAccTitle, @@ -24,7 +26,7 @@ import type { D3Element } from '../../mermaidAPI.js'; export const DEFAULT_ARCHITECTURE_CONFIG: Required = DEFAULT_CONFIG.architecture; export const DEFAULT_ARCHITECTURE_DB: ArchitectureFields = { - services: [], + services: {}, groups: [], lines: [], registeredIds: {}, @@ -35,18 +37,20 @@ let services = DEFAULT_ARCHITECTURE_DB.services; let groups = DEFAULT_ARCHITECTURE_DB.groups; let lines = DEFAULT_ARCHITECTURE_DB.lines; let registeredIds = DEFAULT_ARCHITECTURE_DB.registeredIds; +let datastructures = DEFAULT_ARCHITECTURE_DB.datastructures; let elements: Record = {}; const clear = (): void => { services = structuredClone(DEFAULT_ARCHITECTURE_DB.services); groups = structuredClone(DEFAULT_ARCHITECTURE_DB.groups); lines = structuredClone(DEFAULT_ARCHITECTURE_DB.lines); - registeredIds = structuredClone(DEFAULT_ARCHITECTURE_DB.registeredIds) + registeredIds = structuredClone(DEFAULT_ARCHITECTURE_DB.registeredIds); + datastructures = undefined; elements = {}; commonClear(); }; -const addService = function (id: string, opts: Omit = {}) { +const addService = function (id: string, opts: Omit = {}) { const { icon, in: inside, title } = opts; if (registeredIds[id] !== undefined) { throw new Error(`The service id [${id}] is already in use by another ${registeredIds[id]}`) @@ -65,14 +69,16 @@ const addService = function (id: string, opts: Omit = registeredIds[id] = 'service'; - services.push({ + services[id] = { id, icon, title, + edges: [], in: inside, - }); + }; }; -const getServices = (): ArchitectureService[] => services; + +const getServices = (): ArchitectureService[] => Object.values(services); const addGroup = function (id: string, opts: Omit = {}) { const { icon, in: inside, title } = opts; @@ -100,7 +106,63 @@ const addGroup = function (id: string, opts: Omit = {}) in: inside, }); }; -const getGroups = (): ArchitectureGroup[] => groups; +const getGroups = (): ArchitectureGroup[] => { + return groups +}; + + +const getDataStructures = () => { + console.log('===== createSpatialMap =====') + if (datastructures === undefined) { + // 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(services).reduce<{[id: string]: ArchitectureDirectionPairMap}>((prev, [id, service]) => { + prev[id] = service.edges.reduce((prev, edge) => { + if (edge.lhs_id === id) { // source is LHS + const pair = getArchitectureDirectionPair(edge.lhs_dir, edge.rhs_dir); + if (pair) { + prev[pair] = edge.rhs_id + } + } else { // source is RHS + const pair = getArchitectureDirectionPair(edge.rhs_dir, edge.lhs_dir); + if (pair) { + prev[pair] = edge.lhs_id + } + } + return prev; + }, {}) + return prev + }, {}); + + const [firstId, _] = Object.entries(adjList)[0]; + const spatialMap = {[firstId]: [0,0]}; + const visited = {[firstId]: 1}; + const queue = [firstId]; + // Perform BFS on adjacency list + while(queue.length > 0) { + const id = queue.shift(); + if (id) { + visited[id] = 1 + const adj = adjList[id]; + const [posX, posY] = spatialMap[id]; + Object.entries(adj).forEach(([dir, rhsId]) => { + if (!visited[rhsId]) { + console.log(`${id} -- ${rhsId}`); + spatialMap[rhsId] = shiftPositionByArchitectureDirectionPair([posX, posY], dir as ArchitectureDirectionPair) + queue.push(rhsId); + } + }) + } + } + datastructures = { + adjList, + spatialMap + } + console.log(datastructures) + } + return datastructures; +} const addLine = function ( lhs_id: string, @@ -110,7 +172,6 @@ const addLine = function ( opts: Omit = {} ) { const { title, lhs_into, rhs_into } = opts; - if (!isArchitectureDirection(lhs_dir)) { throw new Error( `Invalid direction given for left hand side of line ${lhs_id}--${rhs_id}. Expected (L,R,T,B) got ${lhs_dir}` @@ -121,8 +182,18 @@ const addLine = function ( `Invalid direction given for right hand side of line ${lhs_id}--${rhs_id}. Expected (L,R,T,B) got ${rhs_dir}` ); } + if (services[lhs_id] === undefined) { + throw new Error( + `The left-hand service [${lhs_id}] does not yet exist. Please create the service before declaring an edge to it.` + ); + } + if (services[rhs_id] === undefined) { + throw new Error( + `The right-hand service [${rhs_id}] does not yet exist. Please create the service before declaring an edge to it.` + ); + } - lines.push({ + const edge = { lhs_id, lhs_dir, rhs_id, @@ -130,7 +201,12 @@ const addLine = function ( title, lhs_into, rhs_into, - }); + } + + lines.push(edge); + + services[lhs_id].edges.push(lines[lines.length - 1]) + services[rhs_id].edges.push(lines[lines.length - 1]) }; const getLines = (): ArchitectureLine[] => lines; @@ -156,6 +232,7 @@ export const db: ArchitectureDB = { getLines, setElementForId, getElementById, + getDataStructures, }; function getConfigField(field: T): Required[T] { diff --git a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts index e51718356..d54663825 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts @@ -14,12 +14,18 @@ import { type ArchitectureLine, type ArchitectureService, isArchitectureDirectionY, + ArchitectureDataStructures, + ArchitectureDirectionPair, + isArchitectureDirectionXY, + ArchitectureDirectionName, + getOppositeArchitectureDirection, } from './architectureTypes.js'; import { select } from 'd3'; import { setupGraphViewbox } from '../../setupGraphViewbox.js'; import type { D3Element } from '../../mermaidAPI.js'; import { drawEdges, drawGroups, drawService } from './svgDraw.js'; import { getConfigField } from './architectureDb.js'; +import { X } from 'vitest/dist/reporters-5f784f42.js'; cytoscape.use(fcose); @@ -97,7 +103,8 @@ function addEdges(lines: ArchitectureLine[], cy: cytoscape.Core) { function layoutArchitecture( services: ArchitectureService[], groups: ArchitectureGroup[], - lines: ArchitectureLine[] + lines: ArchitectureLine[], + {adjList, spatialMap}: ArchitectureDataStructures ): Promise { return new Promise((resolve) => { const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none'); @@ -151,72 +158,76 @@ function layoutArchitecture( addServices(services, cy); addEdges(lines, cy); - /** - * Merge alignment pairs together if they share a common node. - * - * Example: [["a", "b"], ["b", "c"], ["d", "e"]] -> [["a", "b", "c"], ["d", "e"]] - */ - const mergeAlignments = (orig: string[][]): string[][] => { - if (orig.length < 1) return orig; - console.log('===== mergeAlignments ====='); - console.log('Start: ', orig); - // Mapping of discovered ids to their index in the new alignment array - const map: Record = {}; - const newAlignments: string[][] = [orig[0]]; - map[orig[0][0]] = 0; - map[orig[0][1]] = 0; - orig = orig.slice(1); - while (orig.length > 0) { - const pair = orig[0]; - const pairLHSIdx = map[pair[0]]; - const pairRHSIdx = map[pair[1]]; - console.log(pair); - console.log(map); - console.log(newAlignments); - // If neither id appears in the new array, add the pair to the new array - if (pairLHSIdx === undefined && pairRHSIdx === undefined) { - newAlignments.push(pair); - map[pair[0]] = newAlignments.length - 1; - map[pair[1]] = newAlignments.length - 1; - // If the LHS of the pair doesn't appear in the new array, add the LHS to the existing array it shares an id with - } else if (pairLHSIdx === undefined) { - newAlignments[pairRHSIdx].push(pair[0]); - map[pair[0]] = pairRHSIdx; - // If the RHS of the pair doesn't appear in the new array, add the RHS to the existing array it shares an id with - } else if (pairRHSIdx === undefined) { - newAlignments[pairLHSIdx].push(pair[1]); - map[pair[1]] = pairLHSIdx; - // If both ids already have been added to the new array and their index is different, merge all 3 arrays - } else if (pairLHSIdx != pairRHSIdx) { - console.log('ELSE'); - newAlignments.push(pair); - } - orig = orig.slice(1); + // Use the spatial map to create alignment arrays for fcose + const [horizontalAlignments, verticalAlignments] = (() => { + const _horizontalAlignments: Record = {} + const _verticalAlignments: Record = {} + // 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); + }) + + // Merge the values of each object into a list if the inner list has at least 2 elements + return [ + Object.values(_horizontalAlignments).filter(arr => arr.length > 1), + Object.values(_verticalAlignments).filter(arr => arr.length > 1) + ] + })(); + + // Create the relative constraints for fcose by using an inverse of the spatial map and performing BFS on it + const relativeConstraints = (() => { + const _relativeConstraints: fcose.FcoseRelativePlacementConstraint[] = [] + const posToStr = (pos: number[]) => `${pos[0]},${pos[1]}` + const strToPos = (pos: string) => pos.split(',').map(p => parseInt(p)); + const invSpatialMap = Object.fromEntries(Object.entries(spatialMap).map(([id, pos]) => [posToStr(pos), id])) + console.log('===== invSpatialMap =====') + console.log(invSpatialMap); + + // perform BFS + const queue = [posToStr([0,0])]; + const visited: Record = {}; + const directions: Record = { + "L": [-1, 0], + "R": [1, 0], + "T": [0, 1], + "B": [0, -1] } - - console.log('End: ', newAlignments); - console.log('==========================='); - - return newAlignments; - }; - - const horizontalAlignments = cy - .edges() - .filter( - (edge) => - isArchitectureDirectionX(edge.data('sourceDir')) && - isArchitectureDirectionX(edge.data('targetDir')) - ) - .map((edge) => [edge.data('source'), edge.data('target')]); - - const verticalAlignments = cy - .edges() - .filter( - (edge) => - isArchitectureDirectionY(edge.data('sourceDir')) && - isArchitectureDirectionY(edge.data('targetDir')) - ) - .map((edge) => [edge.data('source'), edge.data('target')]); + while (queue.length > 0) { + const curr = queue.shift(); + if (curr) { + visited[curr] = 1 + const currId = invSpatialMap[curr]; + if (currId) { + const currPos = strToPos(curr); + Object.entries(directions).forEach(([dir, shift]) => { + const newPos = posToStr([(currPos[0]+shift[0]), (currPos[1]+shift[1])]); + const newId = invSpatialMap[newPos]; + // If there is an adjacent service to the current one and it has not yet been visited + if (newId && !visited[newPos]) { + queue.push(newPos); + // @ts-ignore cannot determine if left/right or top/bottom are paired together + _relativeConstraints.push({ + [ArchitectureDirectionName[dir as ArchitectureDirection]]: newId, + [ArchitectureDirectionName[getOppositeArchitectureDirection(dir as ArchitectureDirection)]]: currId, + gap: 100 + }) + } + }) + + } + } + } + return _relativeConstraints; + })(); + console.log(`Horizontal Alignments:`) + console.log(horizontalAlignments); + console.log(`Vertical Alignments:`) + console.log(verticalAlignments); + console.log(`Relative Alignments:`) + console.log(relativeConstraints); cy.layout({ name: 'fcose', @@ -242,34 +253,10 @@ function layoutArchitecture( return elasticity }, alignmentConstraint: { - horizontal: mergeAlignments(horizontalAlignments), - vertical: mergeAlignments(verticalAlignments), + horizontal: horizontalAlignments, + vertical: verticalAlignments, }, - relativePlacementConstraint: cy.edges().map((edge) => { - const sourceDir = edge.data('sourceDir') as ArchitectureDirection; - const targetDir = edge.data('targetDir') as ArchitectureDirection; - const sourceId = edge.data('source') as string; - const targetId = edge.data('target') as string; - - let gap = 1.25*getConfigField('iconSize'); - console.log(`relativeConstraint: ${sourceId} ${sourceDir}--${targetDir} ${targetId} (gap=${gap})`); - if (isArchitectureDirectionX(sourceDir) && isArchitectureDirectionX(targetDir)) { - return { - left: sourceDir === 'R' ? sourceId : targetId, - right: sourceDir === 'L' ? sourceId : targetId, - gap, - }; - } else if (isArchitectureDirectionY(sourceDir) && isArchitectureDirectionY(targetDir)) { - return { - top: sourceDir === 'B' ? sourceId : targetId, - bottom: sourceDir === 'T' ? sourceId : targetId, - gap, - }; - } else { - console.log('FALLBACK CASE NEEDED') - } - // TODO: fallback case + RB, TL, etc - }), + relativePlacementConstraint: relativeConstraints } as FcoseLayoutOptions).run(); cy.ready((e) => { log.info('Ready', e); @@ -285,6 +272,7 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram) const services = db.getServices(); const groups = db.getGroups(); const lines = db.getLines(); + const ds = db.getDataStructures(); console.log('Services: ', services); console.log('Lines: ', lines); console.log('Groups: ', groups); @@ -302,7 +290,7 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram) drawServices(db, servicesElem, services, conf); - const cy = await layoutArchitecture(services, groups, lines); + const cy = await layoutArchitecture(services, groups, lines, ds); console.log(cy.nodes().map(node => ({a: node.data()}))); drawEdges(edgesElem, cy); diff --git a/packages/mermaid/src/diagrams/architecture/architectureTypes.ts b/packages/mermaid/src/diagrams/architecture/architectureTypes.ts index 21c620c61..d3d473bde 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureTypes.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureTypes.ts @@ -3,29 +3,104 @@ import type { ArchitectureDiagramConfig } from '../../config.type.js'; import type { D3Element } from '../../mermaidAPI.js'; export type ArchitectureDirection = 'L' | 'R' | 'T' | 'B'; +export type ArchitectureDirectionX = Extract +export type ArchitectureDirectionY = Extract +export const ArchitectureDirectionName = { + 'L': 'left', + 'R': 'right', + 'T': 'top', + 'B': 'bottom', +} as const; + +export const getOppositeArchitectureDirection = function(x: ArchitectureDirection): ArchitectureDirection { + if (isArchitectureDirectionX(x)) { + return x === 'L' ? 'R' : 'L' + } else { + return x === 'T' ? 'B' : 'T' + } +} + export const isArchitectureDirection = function (x: unknown): x is ArchitectureDirection { const temp = x as ArchitectureDirection; return temp === 'L' || temp === 'R' || temp === 'T' || temp === 'B'; }; + export const isArchitectureDirectionX = function ( x: ArchitectureDirection -): x is Extract { - const temp = x as Extract; +): x is ArchitectureDirectionX { + const temp = x as ArchitectureDirectionX; return temp === 'L' || temp === 'R'; }; + export const isArchitectureDirectionY = function ( x: ArchitectureDirection -): x is Extract { - const temp = x as Extract; +): x is ArchitectureDirectionY { + const temp = x as ArchitectureDirectionY; return temp === 'T' || temp === 'B'; }; +export const isArchitectureDirectionXY = function ( + a: ArchitectureDirection, + b: ArchitectureDirection, +) { + const aX_bY = isArchitectureDirectionX(a) && isArchitectureDirectionY(b); + const aY_bX = isArchitectureDirectionY(a) && isArchitectureDirectionX(b); + return aX_bY || aY_bX; +}; + +/** + * Contains LL, RR, TT, BB which are impossible conections + */ +export type InvalidArchitectureDirectionPair = `${ArchitectureDirection}${ArchitectureDirection}` +export type ArchitectureDirectionPair = Exclude +export const isValidArchitectureDirectionPair = function(x: InvalidArchitectureDirectionPair): x is ArchitectureDirectionPair { + return x !== 'LL' && x !== 'RR' && x !== 'TT' && x !== 'BB' +} +export type ArchitectureDirectionPairMap = { + [key in ArchitectureDirectionPair]?: string +} + +/** + * Creates a pair of the directions of each side of an edge. This function should be used instead of manually creating it to ensure that the source is always the first character. + * + * Note: Undefined is returned when sourceDir and targetDir are the same. In theory this should never happen since the diagram parser throws an error if a user defines it as such. + * @param sourceDir + * @param targetDir + * @returns + */ +export const getArchitectureDirectionPair = function (sourceDir: ArchitectureDirection, targetDir: ArchitectureDirection): ArchitectureDirectionPair | undefined { + const pair: `${ArchitectureDirection}${ArchitectureDirection}` = `${sourceDir}${targetDir}`; + return isValidArchitectureDirectionPair(pair) ? pair : undefined +} + +export const shiftPositionByArchitectureDirectionPair = function ([x, y]: number[], pair: ArchitectureDirectionPair): number[] { + const lhs = pair[0] as ArchitectureDirection; + const rhs = pair[1] as ArchitectureDirection; + console.log(`${pair}: (${x},${y})`); + if (isArchitectureDirectionX(lhs)) { + if (isArchitectureDirectionY(rhs)) { + return [x + (lhs === 'L' ? -1 : 1), y + (rhs === 'T' ? 1 : -1)] + } else { + return [x + (lhs === 'L' ? -1 : 1), y] + } + } else { + if (isArchitectureDirectionX(rhs)) { + return [x + (rhs === 'L' ? 1 : -1), y + (lhs === 'T' ? 1 : -1)] + } else { + return [x, y + (lhs === 'T' ? 1 : -1)] + } + } +} + + + export interface ArchitectureStyleOptions { fontFamily: string; } export interface ArchitectureService { id: string; + edges: ArchitectureLine[]; icon?: string; title?: string; in?: string; @@ -66,12 +141,21 @@ export interface ArchitectureDB extends DiagramDB { getLines: () => ArchitectureLine[]; setElementForId: (id: string, element: D3Element) => void; getElementById: (id: string) => D3Element; + getDataStructures: () => ArchitectureDataStructures; +} + +export type ArchitectureAdjacencyList = {[id: string]: ArchitectureDirectionPairMap} +export type ArchitectureSpatialMap = Record +export type ArchitectureDataStructures = { + adjList: ArchitectureAdjacencyList; + spatialMap: ArchitectureSpatialMap; } export interface ArchitectureFields { - services: ArchitectureService[]; + services: Record; groups: ArchitectureGroup[]; lines: ArchitectureLine[]; registeredIds: Record; + datastructures?: ArchitectureDataStructures; config: ArchitectureDiagramConfig; } diff --git a/packages/mermaid/src/diagrams/architecture/svgDraw.ts b/packages/mermaid/src/diagrams/architecture/svgDraw.ts index 446704af6..90ff3ba2e 100644 --- a/packages/mermaid/src/diagrams/architecture/svgDraw.ts +++ b/packages/mermaid/src/diagrams/architecture/svgDraw.ts @@ -2,6 +2,7 @@ import type { D3Element } from '../../mermaidAPI.js'; import { createText } from '../../rendering-util/createText.js'; import type { ArchitectureDB, + ArchitectureDirection, ArchitectureService, } from './architectureTypes.js'; import type { MermaidConfig } from '../../config.type.js'; @@ -10,7 +11,16 @@ import { log } from '../../logger.js'; import { getIcon, isIconNameInUse } from '../../rendering-util/svgRegister.js'; import { getConfigField } from './architectureDb.js'; + declare module 'cytoscape' { + type _EdgeSingularData = { + id: string; + source: string; + sourceDir: ArchitectureDirection; + target: string; + targetDir: ArchitectureDirection; + [key: string]: any; + } interface EdgeSingular { _private: { bodyBounds: unknown; @@ -23,6 +33,9 @@ declare module 'cytoscape' { endY: number; }; }; + // data: (() => _EdgeSingularData) | ((key: T) => _EdgeSingularData[T]) + data(): _EdgeSingularData + data(key: T): _EdgeSingularData[T] } interface NodeSingular { _private: { @@ -84,6 +97,7 @@ export const drawGroups = function (groupsEl: D3Element, cy: cytoscape.Core) { const data = node.data(); if (data.type === 'group') { const { h, w, x1, x2, y1, y2 } = node.boundingBox(); + console.log(`Draw group (${data.id}): pos=(${x1}, ${y1}), dim=(${w}, ${h})`) let bkgElem = groupsEl .append('rect') .attr('x', x1 + halfIconSize) @@ -122,7 +136,7 @@ export const drawService = function ( const textElem = serviceElem.append('g'); createText(textElem, service.title, { useHtmlLabels: false, - width: 110, + width: iconSize * 1.5, classes: 'architecture-service-label', }); textElem @@ -133,16 +147,16 @@ export const drawService = function ( textElem.attr( 'transform', - // TODO: dynamic size 'translate(' + (iconSize / 2) + ', ' + iconSize + ')' ); } let bkgElem = serviceElem.append('g'); if (service.icon) { - if (!isIconNameInUse(service.icon)) { - throw new Error(`Invalid SVG Icon name: "${service.icon}"`); - } + // TODO: should a warning be given to end-users saying which icon names are available? + // if (!isIconNameInUse(service.icon)) { + // throw new Error(`Invalid SVG Icon name: "${service.icon}"`); + // } bkgElem = getIcon(service.icon)?.(bkgElem, iconSize); } else { bkgElem @@ -157,7 +171,7 @@ export const drawService = function ( const { width, height } = serviceElem._groups[0][0].getBBox(); service.width = width; service.height = height; - + console.log(`Draw service (${service.id})`) db.setElementForId(service.id, serviceElem); return 0; };