diff --git a/.build/common.ts b/.build/common.ts index 2497d443f..56e4cabd4 100644 --- a/.build/common.ts +++ b/.build/common.ts @@ -38,6 +38,11 @@ export const packageOptions = { packageName: 'mermaid-layout-tidy-tree', file: 'index.ts', }, + 'mermaid-layout-fcose': { + name: 'mermaid-layout-fcose', + packageName: 'mermaid-layout-fcose', + file: 'index.ts', + }, examples: { name: 'mermaid-examples', packageName: 'examples', diff --git a/packages/mermaid-layout-fcose/package.json b/packages/mermaid-layout-fcose/package.json new file mode 100644 index 000000000..1c60558b3 --- /dev/null +++ b/packages/mermaid-layout-fcose/package.json @@ -0,0 +1,48 @@ +{ + "name": "@mermaid-js/layout-fcose", + "version": "0.1.0", + "description": "FCoSE layout engine for architecture diagrams", + "module": "dist/mermaid-layout-fcose.core.mjs", + "types": "dist/layouts.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/mermaid-layout-fcose.core.mjs", + "types": "./dist/layouts.d.ts" + }, + "./": "./" + }, + "keywords": [ + "diagram", + "markdown", + "fcose", + "mermaid", + "layout", + "architecture" + ], + "scripts": {}, + "repository": { + "type": "git", + "url": "https://github.com/mermaid-js/mermaid" + }, + "contributors": [ + "Knut Sveidqvist" + ], + "license": "MIT", + "dependencies": { + "cytoscape": "^3.27.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0" + }, + "devDependencies": { + "@types/d3": "^7.4.3", + "mermaid": "workspace:^" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + }, + "files": [ + "dist" + ] +} + diff --git a/packages/mermaid-layout-fcose/src/index.ts b/packages/mermaid-layout-fcose/src/index.ts new file mode 100644 index 000000000..c2d338e22 --- /dev/null +++ b/packages/mermaid-layout-fcose/src/index.ts @@ -0,0 +1,22 @@ +/** + * FCoSE Layout Algorithm for Architecture Diagrams + * + * This module provides a layout algorithm implementation using the + * cytoscape-fcose algorithm for positioning nodes and edges in architecture + * diagrams with spatial constraints and group alignments. + * + * The algorithm is optimized for architecture diagrams and supports: + * - Spatial maps for relative positioning + * - Group alignments for organizing related services + * - XY edges with 90-degree bends + * - Complex edge routing + * + * The algorithm follows the unified rendering pattern and can be used + * by architecture diagrams that provide compatible LayoutData with + * architecture-specific data structures. + */ + +export { default } from './layouts.js'; +export * from './types.js'; +export * from './layout.js'; +export { render } from './render.js'; diff --git a/packages/mermaid-layout-fcose/src/layout.ts b/packages/mermaid-layout-fcose/src/layout.ts new file mode 100644 index 000000000..7a6c15751 --- /dev/null +++ b/packages/mermaid-layout-fcose/src/layout.ts @@ -0,0 +1,586 @@ +import type { LayoutData } from 'mermaid'; +import type { LayoutOptions, Position } from 'cytoscape'; +import cytoscape from 'cytoscape'; +import fcose from 'cytoscape-fcose'; +import { select } from 'd3'; +import type { + ArchitectureAlignment, + ArchitectureDataStructures, + ArchitectureGroupAlignments, + ArchitectureSpatialMap, + LayoutResult, + PositionedNode, + PositionedEdge, +} from './types.js'; + +cytoscape.use(fcose as any); + +/** + * Architecture direction types + */ +export type ArchitectureDirection = 'L' | 'R' | 'T' | 'B'; + +const ArchitectureDirectionName = { + L: 'left', + R: 'right', + T: 'top', + B: 'bottom', +} as const; + +function isArchitectureDirectionY(x: ArchitectureDirection): boolean { + return x === 'T' || x === 'B'; +} + +function isArchitectureDirectionXY(a: ArchitectureDirection, b: ArchitectureDirection): boolean { + const aX = a === 'L' || a === 'R'; + const bY = b === 'T' || b === 'B'; + const aY = a === 'T' || a === 'B'; + const bX = b === 'L' || b === 'R'; + return (aX && bY) || (aY && bX); +} + +function getOppositeArchitectureDirection(x: ArchitectureDirection): ArchitectureDirection { + if (x === 'L' || x === 'R') { + return x === 'L' ? 'R' : 'L'; + } else { + return x === 'T' ? 'B' : 'T'; + } +} + +/** + * Execute the fcose layout algorithm on architecture diagram data + * + * This function takes layout data and uses cytoscape-fcose to calculate + * optimal node positions for architecture diagrams with spatial constraints. + * + * @param data - The layout data containing nodes, edges, and configuration + * @returns Promise resolving to layout result with positioned nodes and edges + */ +export function executeFcoseLayout(data: LayoutData): Promise { + return new Promise((resolve, reject) => { + try { + if (!data.nodes || !Array.isArray(data.nodes) || data.nodes.length === 0) { + throw new Error('No nodes found in layout data'); + } + + if (!data.edges || !Array.isArray(data.edges)) { + data.edges = []; + } + + // Extract architecture-specific data structures if available + const dataStructures = data.dataStructures as ArchitectureDataStructures | undefined; + + const spatialMaps = dataStructures?.spatialMaps ?? []; + const groupAlignments = dataStructures?.groupAlignments ?? {}; + + // Get icon size from config (default to 50) + // Try to get from architecture config, or use a reasonable default + const iconSize = data.config?.architecture?.iconSize || data.config?.iconSize || 50; + + // Create a hidden container for cytoscape + const renderEl = select('body') + .append('div') + .attr('id', 'cy-fcose') + .attr('style', 'display:none'); + const cy = cytoscape({ + container: document.getElementById('cy-fcose'), + style: [ + { + selector: 'edge', + style: { + 'curve-style': 'straight', + label: 'data(label)', + }, + }, + { + selector: 'edge.segments', + style: { + 'curve-style': 'segments', + 'segment-weights': '0', + 'segment-distances': [0.5], + }, + }, + { + selector: 'node', + style: { + // @ts-ignore Incorrect library types + 'compound-sizing-wrt-labels': 'include', + }, + }, + { + selector: 'node[label]', + style: { + 'text-valign': 'bottom', + 'text-halign': 'center', + }, + }, + { + selector: '.node-service', + style: { + label: 'data(label)', + width: 'data(width)', + height: 'data(height)', + }, + }, + { + selector: '.node-junction', + style: { + width: 'data(width)', + height: 'data(height)', + }, + }, + { + selector: '.node-group', + style: { + // @ts-ignore Incorrect library types + padding: `${iconSize * 0.5}px`, + }, + }, + ], + layout: { + name: 'grid', + boundingBox: { + x1: 0, + x2: 100, + y1: 0, + y2: 100, + }, + }, + }); + + // Add nodes to cytoscape + // First add groups, then services/junctions (to ensure parents exist before children) + const nodeMap = new Map(); + const groups = data.nodes.filter((n) => n.isGroup); + const services = data.nodes.filter((n) => !n.isGroup); + + // Add groups first + groups.forEach((node) => { + const cyNode = cy.add({ + group: 'nodes', + data: { + id: node.id, + label: node.label || '', + parent: node.parentId, + type: 'group', + }, + classes: 'node-group', + }); + nodeMap.set(node.id, { node: cyNode, originalNode: node }); + }); + + // Then add services and junctions + services.forEach((node) => { + const nodeType = (node as any).type === 'junction' ? 'junction' : 'service'; + const cyNode = cy.add({ + group: 'nodes', + data: { + id: node.id, + label: node.label || '', + parent: node.parentId, + width: node.width || iconSize, + height: node.height || iconSize, + type: nodeType, + }, + classes: nodeType === 'junction' ? 'node-junction' : 'node-service', + }); + nodeMap.set(node.id, { node: cyNode, originalNode: node }); + }); + + // Add edges to cytoscape + const edgeMap = new Map(); + data.edges.forEach((edge) => { + const edgeData: any = { + id: edge.id, + source: edge.start || edge.source, + target: edge.end || edge.target, + label: edge.label || '', + }; + + // Preserve architecture-specific edge data + if ((edge as any).lhsDir) { + edgeData.sourceDir = (edge as any).lhsDir; + edgeData.targetDir = (edge as any).rhsDir; + } + + const edgeType = + (edge as any).lhsDir && (edge as any).rhsDir + ? isArchitectureDirectionXY((edge as any).lhsDir, (edge as any).rhsDir) + ? 'segments' + : 'straight' + : 'straight'; + + const cyEdge = cy.add({ + group: 'edges', + data: edgeData, + classes: edgeType, + }); + edgeMap.set(edge.id, { edge: cyEdge, originalEdge: edge }); + }); + + // Make cytoscape care about the dimensions of the nodes + cy.nodes().forEach(function (n) { + n.layoutDimensions = () => { + const nodeData = n.data(); + return { w: nodeData.width || iconSize, h: nodeData.height || iconSize }; + }; + }); + + // Get alignment constraints + const alignmentConstraint = getAlignments(data.nodes, spatialMaps, groupAlignments); + + // Get relative placement constraints + const relativePlacementConstraint = getRelativeConstraints(spatialMaps, data.nodes, iconSize); + + // Run fcose layout + const layout = cy.layout({ + name: 'fcose', + quality: 'proof', + styleEnabled: false, + animate: false, + nodeDimensionsIncludeLabels: false, + idealEdgeLength(edge: any) { + const [nodeA, nodeB] = edge.connectedNodes(); + const parentA = nodeA.data('parent'); + const parentB = nodeB.data('parent'); + const elasticity = parentA === parentB ? 1.5 * iconSize : 0.5 * iconSize; + return elasticity; + }, + edgeElasticity(edge: any) { + const [nodeA, nodeB] = edge.connectedNodes(); + const parentA = nodeA.data('parent'); + const parentB = nodeB.data('parent'); + const elasticity = parentA === parentB ? 0.45 : 0.001; + return elasticity; + }, + alignmentConstraint, + relativePlacementConstraint, + } as LayoutOptions); + + // Handle XY edges (edges with bends) + layout.one('layoutstop', () => { + function getSegmentWeights( + source: Position, + target: Position, + pointX: number, + pointY: number + ) { + const { x: sX, y: sY } = source; + const { x: tX, y: tY } = target; + + let D = + (pointY - sY + ((sX - pointX) * (sY - tY)) / (sX - tX)) / + Math.sqrt(1 + Math.pow((sY - tY) / (sX - tX), 2)); + let W = Math.sqrt(Math.pow(pointY - sY, 2) + Math.pow(pointX - sX, 2) - Math.pow(D, 2)); + + const distAB = Math.sqrt(Math.pow(tX - sX, 2) + Math.pow(tY - sY, 2)); + W = W / distAB; + + let delta1 = (tX - sX) * (pointY - sY) - (tY - sY) * (pointX - sX); + delta1 = delta1 >= 0 ? 1 : -1; + + let delta2 = (tX - sX) * (pointX - sX) + (tY - sY) * (pointY - sY); + delta2 = delta2 >= 0 ? 1 : -1; + + D = Math.abs(D) * delta1; + W = W * delta2; + + return { distances: D, weights: W }; + } + + cy.startBatch(); + cy.edges().forEach((cyEdge) => { + // Check if edge has data method and data exists + if ( + cyEdge && + typeof cyEdge.data === 'function' && + typeof cyEdge.source === 'function' && + typeof cyEdge.target === 'function' + ) { + try { + const edgeData = cyEdge.data(); + if (edgeData?.sourceDir && edgeData?.targetDir) { + const sourceNode = cyEdge.source(); + const targetNode = cyEdge.target(); + if ( + sourceNode && + targetNode && + typeof sourceNode.position === 'function' && + typeof targetNode.position === 'function' + ) { + const { x: sX, y: sY } = sourceNode.position(); + const { x: tX, y: tY } = targetNode.position(); + if ( + sX !== tX && + sY !== tY && + !isNaN(sX) && + !isNaN(sY) && + !isNaN(tX) && + !isNaN(tY) + ) { + const sourceDir = edgeData.sourceDir as ArchitectureDirection; + if ( + typeof cyEdge.sourceEndpoint === 'function' && + typeof cyEdge.targetEndpoint === 'function' + ) { + const sEP = cyEdge.sourceEndpoint(); + const tEP = cyEdge.targetEndpoint(); + if ( + sEP && + tEP && + typeof sEP.x === 'number' && + typeof sEP.y === 'number' && + typeof tEP.x === 'number' && + typeof tEP.y === 'number' + ) { + const [pointX, pointY] = isArchitectureDirectionY(sourceDir) + ? [sEP.x, tEP.y] + : [tEP.x, sEP.y]; + const { weights, distances } = getSegmentWeights(sEP, tEP, pointX, pointY); + if (typeof cyEdge.style === 'function') { + cyEdge.style('segment-distances', distances); + cyEdge.style('segment-weights', weights); + } + } + } + } + } + } + } catch (error) { + // skip edges that can't be processed + void error; + } + } + }); + cy.endBatch(); + layout.run(); + }); + + layout.run(); + + cy.ready(() => { + // Extract positioned nodes + const positionedNodes: PositionedNode[] = []; + cy.nodes().forEach((cyNode) => { + if (cyNode && typeof cyNode.position === 'function') { + const pos = cyNode.position(); + const nodeData = nodeMap.get(cyNode.id()); + if (nodeData && pos && typeof pos.x === 'number' && typeof pos.y === 'number') { + positionedNodes.push({ + id: cyNode.id(), + x: pos.x, + y: pos.y, + width: typeof cyNode.data === 'function' ? cyNode.data('width') : undefined, + height: typeof cyNode.data === 'function' ? cyNode.data('height') : undefined, + originalNode: nodeData.originalNode, + }); + } + } + }); + + // Extract positioned edges + const positionedEdges: PositionedEdge[] = []; + cy.edges().forEach((cyEdge) => { + if ( + cyEdge && + typeof cyEdge.id === 'function' && + typeof cyEdge.source === 'function' && + typeof cyEdge.target === 'function' + ) { + const edgeData = edgeMap.get(cyEdge.id()); + if (edgeData) { + const sourceNode = cyEdge.source(); + const targetNode = cyEdge.target(); + if ( + sourceNode && + targetNode && + typeof sourceNode.position === 'function' && + typeof targetNode.position === 'function' + ) { + const sourcePos = sourceNode.position(); + const targetPos = targetNode.position(); + if ( + sourcePos && + targetPos && + typeof sourcePos.x === 'number' && + typeof sourcePos.y === 'number' && + typeof targetPos.x === 'number' && + typeof targetPos.y === 'number' + ) { + positionedEdges.push({ + id: cyEdge.id(), + source: sourceNode.id(), + target: targetNode.id(), + points: [ + { x: sourcePos.x, y: sourcePos.y }, + { x: targetPos.x, y: targetPos.y }, + ], + }); + } + } + } + } + }); + + // Clean up + renderEl.remove(); + + resolve({ + nodes: positionedNodes, + edges: positionedEdges, + }); + }); + } catch (error) { + reject(error); + } + }); +} + +/** + * Get alignment constraints for fcose + */ +function getAlignments( + nodes: LayoutData['nodes'], + spatialMaps: ArchitectureSpatialMap[], + groupAlignments: ArchitectureGroupAlignments +): fcose.FcoseAlignmentConstraint { + const flattenAlignments = ( + alignmentObj: Record>, + alignmentDir: ArchitectureAlignment + ): Record => { + return Object.entries(alignmentObj).reduce( + (prev, [dir, alignments]) => { + let cnt = 0; + const arr = Object.entries(alignments); + if (arr.length === 1) { + 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]; + + if (alignment === alignmentDir) { + prev[dir] ??= []; + prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds]; + } else if (aGroupId === 'default' || bGroupId === 'default') { + prev[dir] ??= []; + prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds]; + } else { + const keyA = `${dir}-${cnt++}`; + prev[keyA] = aNodeIds; + const keyB = `${dir}-${cnt++}`; + prev[keyB] = bNodeIds; + } + } + } + return prev; + }, + {} as Record + ); + }; + + // Create a node lookup by group + const nodeGroupMap = new Map(); + nodes.forEach((node) => { + nodeGroupMap.set(node.id, node.parentId || 'default'); + }); + + const alignments = spatialMaps.map((spatialMap) => { + const horizontalAlignments: Record> = {}; + const verticalAlignments: Record> = {}; + + Object.entries(spatialMap).forEach(([id, [x, y]]) => { + const nodeGroup = nodeGroupMap.get(id) ?? 'default'; + + horizontalAlignments[y] ??= {}; + horizontalAlignments[y][nodeGroup] ??= []; + horizontalAlignments[y][nodeGroup].push(id); + + verticalAlignments[x] ??= {}; + verticalAlignments[x][nodeGroup] ??= []; + verticalAlignments[x][nodeGroup].push(id); + }); + + return { + horiz: Object.values(flattenAlignments(horizontalAlignments, 'horizontal')).filter( + (arr) => arr.length > 1 + ), + vert: Object.values(flattenAlignments(verticalAlignments, 'vertical')).filter( + (arr) => arr.length > 1 + ), + }; + }); + + const [horizontal, vertical] = alignments.reduce( + ([prevHoriz, prevVert], { horiz, vert }) => { + return [ + [...prevHoriz, ...horiz], + [...prevVert, ...vert], + ]; + }, + [[] as string[][], [] as string[][]] + ); + + return { + horizontal, + vertical, + }; +} + +/** + * Get relative placement constraints for fcose + */ +function getRelativeConstraints( + spatialMaps: ArchitectureSpatialMap[], + nodes: LayoutData['nodes'], + iconSize: number +): fcose.FcoseRelativePlacementConstraint[] { + const relativeConstraints: fcose.FcoseRelativePlacementConstraint[] = []; + const posToStr = (pos: number[]) => `${pos[0]},${pos[1]}`; + const strToPos = (pos: string) => pos.split(',').map((p) => parseInt(p)); + + spatialMaps.forEach((spatialMap) => { + const invSpatialMap = Object.fromEntries( + Object.entries(spatialMap).map(([id, pos]) => [posToStr(pos), id]) + ); + + const queue = [posToStr([0, 0])]; + const visited: Record = {}; + const directions: Record = { + L: [-1, 0], + R: [1, 0], + T: [0, 1], + B: [0, -1], + }; + + 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 (newId && !visited[newPos]) { + queue.push(newPos); + relativeConstraints.push({ + [ArchitectureDirectionName[dir as ArchitectureDirection]]: newId, + [ArchitectureDirectionName[ + getOppositeArchitectureDirection(dir as ArchitectureDirection) + ]]: currId, + gap: 1.5 * iconSize, + } as any); + } + }); + } + } + } + }); + + return relativeConstraints; +} diff --git a/packages/mermaid-layout-fcose/src/layouts.ts b/packages/mermaid-layout-fcose/src/layouts.ts new file mode 100644 index 000000000..69299210c --- /dev/null +++ b/packages/mermaid-layout-fcose/src/layouts.ts @@ -0,0 +1,13 @@ +import type { LayoutLoaderDefinition } from 'mermaid'; + +const loader = async () => await import(`./render.js`); + +const fcoseLayout: LayoutLoaderDefinition[] = [ + { + name: 'architecture-fcose', + loader, + algorithm: 'architecture-fcose', + }, +]; + +export default fcoseLayout; diff --git a/packages/mermaid-layout-fcose/src/render.ts b/packages/mermaid-layout-fcose/src/render.ts new file mode 100644 index 000000000..64f6624e0 --- /dev/null +++ b/packages/mermaid-layout-fcose/src/render.ts @@ -0,0 +1,157 @@ +import type { InternalHelpers, LayoutData, RenderOptions, SVG } from 'mermaid'; +import { executeFcoseLayout } from './layout.js'; + +interface NodeWithPosition { + id: string; + x?: number; + y?: number; + width?: number; + height?: number; + domId?: any; + [key: string]: any; +} + +/** + * Render function for fcose layout algorithm + + * The fcose layout is optimized for architecture diagrams with spatial constraints + * and group alignments. + */ +export const render = async ( + data4Layout: LayoutData, + svg: SVG, + { + insertCluster, + insertEdge, + insertEdgeLabel, + insertMarkers, + insertNode, + log, + positionEdgeLabel, + }: InternalHelpers, + { algorithm: _algorithm }: RenderOptions +) => { + const nodeDb: Record = {}; + const clusterDb: Record = {}; + + const element = svg.select('g'); + insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId); + + const subGraphsEl = element.insert('g').attr('class', 'subgraphs'); + const edgePaths = element.insert('g').attr('class', 'edgePaths'); + const edgeLabels = element.insert('g').attr('class', 'edgeLabels'); + const nodes = element.insert('g').attr('class', 'nodes'); + + await Promise.all( + data4Layout.nodes.map(async (node) => { + if (node.isGroup) { + const clusterNode: NodeWithPosition = { + ...node, + id: node.id, + width: node.width, + height: node.height, + }; + clusterDb[node.id] = clusterNode; + nodeDb[node.id] = clusterNode; + + await insertCluster(subGraphsEl, node); + } else { + const nodeWithPosition: NodeWithPosition = { + ...node, + id: node.id, + width: node.width, + height: node.height, + }; + nodeDb[node.id] = nodeWithPosition; + + const nodeEl = await insertNode(nodes, node, { + config: data4Layout.config, + dir: data4Layout.direction ?? 'TB', + }); + + const boundingBox = nodeEl.node()!.getBBox(); + nodeWithPosition.width = boundingBox.width; + nodeWithPosition.height = boundingBox.height; + nodeWithPosition.domId = nodeEl; + + log.debug(`Node ${node.id} dimensions: ${boundingBox.width}x${boundingBox.height}`); + } + }) + ); + + const updatedLayoutData = { + ...data4Layout, + nodes: data4Layout.nodes.map((node) => { + const nodeWithDimensions = nodeDb[node.id]; + return { + ...node, + width: nodeWithDimensions.width ?? node.width ?? 100, + height: nodeWithDimensions.height ?? node.height ?? 50, + }; + }), + }; + + const layoutResult = await executeFcoseLayout(updatedLayoutData); + + log.debug('Positioning nodes based on fcose layout results'); + + layoutResult.nodes.forEach((positionedNode) => { + const node = nodeDb[positionedNode.id]; + if (node?.domId) { + node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`); + node.x = positionedNode.x; + node.y = positionedNode.y; + log.debug(`Positioned node ${node.id} at (${positionedNode.x}, ${positionedNode.y})`); + } + }); + + await Promise.all( + data4Layout.edges.map(async (edge) => { + await insertEdgeLabel(edgeLabels, edge); + + const startNode = nodeDb[edge.start ?? '']; + const endNode = nodeDb[edge.end ?? '']; + + if (startNode && endNode) { + const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); + + if (positionedEdge) { + const edgeWithPath = { + ...edge, + points: positionedEdge.points, + }; + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + + positionEdgeLabel(edgeWithPath, paths); + } else { + const edgeWithPath = { + ...edge, + points: [ + { x: startNode.x ?? 0, y: startNode.y ?? 0 }, + { x: endNode.x ?? 0, y: endNode.y ?? 0 }, + ], + }; + + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + positionEdgeLabel(edgeWithPath, paths); + } + } + }) + ); +}; diff --git a/packages/mermaid-layout-fcose/src/types.ts b/packages/mermaid-layout-fcose/src/types.ts new file mode 100644 index 000000000..5e8113913 --- /dev/null +++ b/packages/mermaid-layout-fcose/src/types.ts @@ -0,0 +1,53 @@ +import type { LayoutData } from 'mermaid'; + +export type Node = LayoutData['nodes'][number]; +export type Edge = LayoutData['edges'][number]; + +/** + * Positioned node after layout calculation + */ +export interface PositionedNode { + id: string; + x: number; + y: number; + width?: number; + height?: number; + originalNode?: Node; + [key: string]: unknown; +} + +/** + * Positioned edge after layout calculation + */ +export interface PositionedEdge { + id: string; + source: string; + target: string; + points: { x: number; y: number }[]; + [key: string]: unknown; +} + +/** + * Result of layout algorithm execution + */ +export interface LayoutResult { + nodes: PositionedNode[]; + edges: PositionedEdge[]; +} + +/** + * Architecture-specific data structures for fcose layout + */ +export type ArchitectureSpatialMap = Record; + +export type ArchitectureAlignment = 'vertical' | 'horizontal' | 'bend'; + +export type ArchitectureGroupAlignments = Record< + string, + Record> +>; + +export interface ArchitectureDataStructures { + spatialMaps: ArchitectureSpatialMap[]; + groupAlignments: ArchitectureGroupAlignments; +} diff --git a/packages/mermaid-layout-fcose/tsconfig.json b/packages/mermaid-layout-fcose/tsconfig.json new file mode 100644 index 000000000..25d566959 --- /dev/null +++ b/packages/mermaid-layout-fcose/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["vitest/importMeta", "vitest/globals"] + }, + "include": ["./src/**/*.ts", "./src/**/*.d.ts"], + "typeRoots": ["./src/types"] +} + diff --git a/packages/mermaid/src/diagrams/architecture/architectureRenderer-unified.ts b/packages/mermaid/src/diagrams/architecture/architectureRenderer-unified.ts index 1edb1feba..880994df6 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureRenderer-unified.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureRenderer-unified.ts @@ -31,7 +31,8 @@ export const draw = async function (_text: string, id: string, _version: string, const svg = getDiagramElement(id, securityLevel); data4Layout.type = diag.type; - data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout, { fallback: 'dagre' }); + const layoutToUse = layout || 'architecture-fcose'; + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layoutToUse, { fallback: 'dagre' }); data4Layout.nodeSpacing = 100; data4Layout.rankSpacing = 100;