From a92c3bb251b5bec4181758b224835a89baf6176c Mon Sep 17 00:00:00 2001 From: darshanr0107 Date: Fri, 1 Aug 2025 13:36:35 +0530 Subject: [PATCH] extract tidy-tree layout into separate package on-behalf-of: @Mermaid-Chart --- packages/mermaid-layout-tidy-tree/README.md | 61 +++++ .../mermaid-layout-tidy-tree/package.json | 46 ++++ .../src}/index.ts | 7 +- .../src}/layout.test.ts | 69 ++---- .../src}/layout.ts | 208 ++++++++---------- .../mermaid-layout-tidy-tree/src/layouts.ts | 13 ++ .../src/non-layered-tidy-tree-layout.ts | 18 ++ .../src}/render.ts | 45 +--- .../src}/types.ts | 22 +- .../mermaid-layout-tidy-tree/tsconfig.json | 10 + packages/mermaid/src/rendering-util/render.ts | 2 +- pnpm-lock.yaml | 15 ++ 12 files changed, 298 insertions(+), 218 deletions(-) create mode 100644 packages/mermaid-layout-tidy-tree/README.md create mode 100644 packages/mermaid-layout-tidy-tree/package.json rename packages/{mermaid/src/rendering-util/layout-algorithms/tidy-tree => mermaid-layout-tidy-tree/src}/index.ts (93%) rename packages/{mermaid/src/rendering-util/layout-algorithms/tidy-tree => mermaid-layout-tidy-tree/src}/layout.test.ts (77%) rename packages/{mermaid/src/rendering-util/layout-algorithms/tidy-tree => mermaid-layout-tidy-tree/src}/layout.ts (69%) create mode 100644 packages/mermaid-layout-tidy-tree/src/layouts.ts create mode 100644 packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.ts rename packages/{mermaid/src/rendering-util/layout-algorithms/tidy-tree => mermaid-layout-tidy-tree/src}/render.ts (72%) rename packages/{mermaid/src/rendering-util/layout-algorithms/tidy-tree => mermaid-layout-tidy-tree/src}/types.ts (64%) create mode 100644 packages/mermaid-layout-tidy-tree/tsconfig.json diff --git a/packages/mermaid-layout-tidy-tree/README.md b/packages/mermaid-layout-tidy-tree/README.md new file mode 100644 index 000000000..47cf82d94 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/README.md @@ -0,0 +1,61 @@ +# @mermaid-js/layout-tidy-tree + +This package provides a bidirectional tidy tree layout engine for Mermaid based on the non-layered-tidy-tree-layout algorithm. + +> [!NOTE] +> The Tidy Tree Layout engine will not be available in all providers that support mermaid by default. +> The websites will have to install the @mermaid-js/layout-tidy-tree package to use the Tidy Tree layout engine. + +## Usage + +flowchart-tidy-tree TD +A --> B +A --> C + +--- + +config: +layout: tidy-tree + +--- + +flowchart TD +A --> B +A --> C + +### With bundlers + +sh +npm install @mermaid-js/layout-tidy-tree + +ts +import mermaid from 'mermaid'; +import tidyTreeLayouts from '@mermaid-js/layout-tidy-tree'; + +mermaid.registerLayoutLoaders(tidyTreeLayouts); + +### With CDN + +html + + + +## Supported layouts + +tidy-tree: The bidirectional tidy tree layout + +The bidirectional tidy tree layout algorithm creates two separate trees that grow horizontally in opposite directions from a central root node: +Left tree: grows horizontally to the left (children alternate: 1st, 3rd, 5th...) +Right tree: grows horizontally to the right (children alternate: 2nd, 4th, 6th...) + +This creates a balanced, symmetric layout that is ideal for mindmaps, organizational charts, and other tree-based diagrams. + +Layout Structure: +[Child 3] ← [Child 1] ← [Root] → [Child 2] → [Child 4] +↓ ↓ ↓ ↓ +[GrandChild] [GrandChild] [GrandChild] [GrandChild] diff --git a/packages/mermaid-layout-tidy-tree/package.json b/packages/mermaid-layout-tidy-tree/package.json new file mode 100644 index 000000000..2e21d3b39 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/package.json @@ -0,0 +1,46 @@ +{ + "name": "@mermaid-js/layout-tidy-tree", + "version": "0.1.0", + "description": "Tidy-tree layout engine for mermaid", + "module": "dist/mermaid-layout-tidy-tree.core.mjs", + "types": "dist/layouts.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/mermaid-layout-tidy-tree.core.mjs", + "types": "./dist/layouts.d.ts" + }, + "./": "./" + }, + "keywords": [ + "diagram", + "markdown", + "tidy-tree", + "mermaid", + "layout" + ], + "scripts": {}, + "repository": { + "type": "git", + "url": "https://github.com/mermaid-js/mermaid" + }, + "contributors": [ + "Knut Sveidqvist", + "Sidharth Vinod" + ], + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "non-layered-tidy-tree-layout": "^2.0.2" + }, + "devDependencies": { + "@types/d3": "^7.4.3", + "mermaid": "workspace:^" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + }, + "files": [ + "dist" + ] +} \ No newline at end of file diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/index.ts b/packages/mermaid-layout-tidy-tree/src/index.ts similarity index 93% rename from packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/index.ts rename to packages/mermaid-layout-tidy-tree/src/index.ts index 3764a634e..2be1b59e6 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/index.ts +++ b/packages/mermaid-layout-tidy-tree/src/index.ts @@ -1,5 +1,3 @@ -import { render as renderWithTidyTree } from './render.js'; - /** * Bidirectional Tidy-Tree Layout Algorithm for Generic Diagrams * @@ -46,4 +44,7 @@ import { render as renderWithTidyTree } from './render.js'; * @param helpers - Internal helper functions for rendering * @param options - Rendering options */ -export const render = renderWithTidyTree; +export { default } from './layouts.js'; +export * from './types.js'; +export * from './layout.js'; +export { render } from './render.js'; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/layout.test.ts b/packages/mermaid-layout-tidy-tree/src/layout.test.ts similarity index 77% rename from packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/layout.test.ts rename to packages/mermaid-layout-tidy-tree/src/layout.test.ts index 20d3ffe24..a0772152b 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/layout.test.ts +++ b/packages/mermaid-layout-tidy-tree/src/layout.test.ts @@ -5,10 +5,8 @@ vi.mock('non-layered-tidy-tree-layout', () => ({ BoundingBox: vi.fn().mockImplementation(() => ({})), Layout: vi.fn().mockImplementation(() => ({ layout: vi.fn().mockImplementation((treeData) => { - // Return a result based on the input tree structure const result = { ...treeData }; - // Set positions for the virtual root (if it exists) if (result.id?.toString().startsWith('virtual-root')) { result.x = 0; result.y = 0; @@ -17,15 +15,13 @@ vi.mock('non-layered-tidy-tree-layout', () => ({ result.y = 50; } - // Set positions for children if they exist if (result.children) { - result.children.forEach((child:Node, index:number) => { + result.children.forEach((child: any, index: number) => { child.x = 50 + index * 100; child.y = 100; - // Recursively position grandchildren if (child.children) { - child.children.forEach((grandchild, gIndex:number) => { + child.children.forEach((grandchild: any, gIndex: number) => { grandchild.x = 25 + gIndex * 50; grandchild.y = 200; }); @@ -46,12 +42,9 @@ vi.mock('non-layered-tidy-tree-layout', () => ({ })), })); -// Import modules after mocks import { executeTidyTreeLayout, validateLayoutData } from './layout.js'; import type { LayoutResult } from './types.js'; -import type { LayoutData } from '../../types.js'; -import type { MermaidConfig } from '../../../config.type.js'; -import type { Node } from '../../../../dist/rendering-util/types.js'; +import type { LayoutData, MermaidConfig } from 'mermaid'; describe('Tidy-Tree Layout Algorithm', () => { let mockConfig: MermaidConfig; @@ -275,7 +268,7 @@ describe('Tidy-Tree Layout Algorithm', () => { const singleNodeData = { ...mockLayoutData, edges: [], - nodes: [mockLayoutData.nodes[0]], // Only root node + nodes: [mockLayoutData.nodes[0]], }; const result = await executeTidyTreeLayout(singleNodeData, mockConfig); @@ -288,15 +281,13 @@ describe('Tidy-Tree Layout Algorithm', () => { const result = await executeTidyTreeLayout(mockLayoutData, mockConfig); expect(result).toBeDefined(); - expect(result.nodes).toHaveLength(5); // root + 4 children + expect(result.nodes).toHaveLength(5); - // Find the root node (should be at center) const rootNode = result.nodes.find((node) => node.id === 'root'); expect(rootNode).toBeDefined(); - expect(rootNode!.x).toBe(0); // Root should be at center - expect(rootNode!.y).toBe(0); // Root should be at center + expect(rootNode!.x).toBe(0); + expect(rootNode!.y).toBe(20); - // Check that children are positioned on left and right sides const child1 = result.nodes.find((node) => node.id === 'child1'); const child2 = result.nodes.find((node) => node.id === 'child2'); const child3 = result.nodes.find((node) => node.id === 'child3'); @@ -307,25 +298,19 @@ describe('Tidy-Tree Layout Algorithm', () => { expect(child3).toBeDefined(); expect(child4).toBeDefined(); - // Child1 and Child3 should be on the left (negative x), Child2 and Child4 on the right (positive x) - // In bidirectional layout, trees grow horizontally from the root - expect(child1!.x).toBeLessThan(rootNode!.x); // Left side (grows left) - expect(child2!.x).toBeGreaterThan(rootNode!.x); // Right side (grows right) - expect(child3!.x).toBeLessThan(rootNode!.x); // Left side (grows left) - expect(child4!.x).toBeGreaterThan(rootNode!.x); // Right side (grows right) + expect(child1!.x).toBeLessThan(rootNode!.x); + expect(child2!.x).toBeGreaterThan(rootNode!.x); + expect(child3!.x).toBeLessThan(rootNode!.x); + expect(child4!.x).toBeGreaterThan(rootNode!.x); - // Verify that the layout is truly bidirectional (horizontal growth) - // Left tree children should be positioned to the left of root - expect(child1!.x).toBeLessThan(-100); // Should be significantly left of center - expect(child3!.x).toBeLessThan(-100); // Should be significantly left of center + expect(child1!.x).toBeLessThan(-100); + expect(child3!.x).toBeLessThan(-100); - // Right tree children should be positioned to the right of root - expect(child2!.x).toBeGreaterThan(100); // Should be significantly right of center - expect(child4!.x).toBeGreaterThan(100); // Should be significantly right of center + expect(child2!.x).toBeGreaterThan(100); + expect(child4!.x).toBeGreaterThan(100); }); it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => { - // Create a test case with nodes that have different heights to test transposition const testData = { ...mockLayoutData, nodes: [ @@ -349,7 +334,7 @@ describe('Tidy-Tree Layout Algorithm', () => { isGroup: false, shape: 'rect' as const, width: 80, - height: 120, // Tall node + height: 120, padding: 10, x: 0, y: 0, @@ -363,7 +348,7 @@ describe('Tidy-Tree Layout Algorithm', () => { isGroup: false, shape: 'rect' as const, width: 80, - height: 30, // Short node + height: 30, padding: 10, x: 0, y: 0, @@ -401,9 +386,8 @@ describe('Tidy-Tree Layout Algorithm', () => { const result = await executeTidyTreeLayout(testData, mockConfig); expect(result).toBeDefined(); - expect(result.nodes).toHaveLength(3); // root + 2 children + expect(result.nodes).toHaveLength(3); - // Find all nodes const rootNode = result.nodes.find((node) => node.id === 'root'); const tallChild = result.nodes.find((node) => node.id === 'tall-child'); const shortChild = result.nodes.find((node) => node.id === 'short-child'); @@ -412,20 +396,15 @@ describe('Tidy-Tree Layout Algorithm', () => { expect(tallChild).toBeDefined(); expect(shortChild).toBeDefined(); - // Verify that nodes are positioned correctly with proper transposition - // The tall child and short child should be on opposite sides - expect(tallChild!.x).not.toBe(shortChild!.x); // Should be on different sides + expect(tallChild!.x).not.toBe(shortChild!.x); - // Verify that the original dimensions are preserved (not transposed in final output) - expect(tallChild!.width).toBe(80); // Original width preserved - expect(tallChild!.height).toBe(120); // Original height preserved - expect(shortChild!.width).toBe(80); // Original width preserved - expect(shortChild!.height).toBe(30); // Original height preserved + expect(tallChild!.width).toBe(80); + expect(tallChild!.height).toBe(120); + expect(shortChild!.width).toBe(80); + expect(shortChild!.height).toBe(30); - // Verify that nodes don't overlap vertically (the transposition fix) - // Both children should have reasonable Y positions that don't cause overlap const yDifference = Math.abs(tallChild!.y - shortChild!.y); - expect(yDifference).toBeGreaterThanOrEqual(0); // Should have proper vertical spacing + expect(yDifference).toBeGreaterThanOrEqual(0); }); }); }); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/layout.ts b/packages/mermaid-layout-tidy-tree/src/layout.ts similarity index 69% rename from packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/layout.ts rename to packages/mermaid-layout-tidy-tree/src/layout.ts index fe3d0218b..7519ffd4a 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/layout.ts +++ b/packages/mermaid-layout-tidy-tree/src/layout.ts @@ -1,9 +1,13 @@ import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout'; -import type { MermaidConfig } from '../../../config.type.js'; -import { log } from '../../../logger.js'; -import type { LayoutData, Node, Edge } from '../../types.js'; -import type { LayoutResult, TidyTreeNode, PositionedNode, PositionedEdge } from './types.js'; -import { intersection } from '../../rendering-elements/edges.js'; +import type { MermaidConfig, LayoutData } from 'mermaid'; +import type { + LayoutResult, + TidyTreeNode, + PositionedNode, + PositionedEdge, + Node, + Edge, +} from './types.js'; /** * Execute the tidy-tree layout algorithm on generic layout data @@ -20,48 +24,34 @@ export function executeTidyTreeLayout( _config: MermaidConfig ): Promise { let intersectionShift = 50; - log.debug('APA01 Starting tidy-tree layout algorithm'); return new Promise((resolve, reject) => { try { - // Validate input data 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 = []; // Allow empty edges for single-node trees + data.edges = []; } - // Convert layout data to dual-tree format (left and right trees) const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data); - // Configure tidy-tree layout with dynamic gap based on node heights - // Since we transpose coordinates, the gap becomes horizontal spacing in final layout - // We need to ensure enough space for the tallest nodes - const gap = 20; // Math.max(20, maxNodeHeight + 20); // Ensure minimum gap plus node height - const bottomPadding = 40; // Horizontal gap between levels + const gap = 20; + const bottomPadding = 40; intersectionShift = 30; - // if (maxChildren > 5) { - // bottomPadding = 50; - // intersectionShift = 50; - // } - const bb = new BoundingBox(gap, bottomPadding); const layout = new Layout(bb); - // Execute layout algorithm for both trees let leftResult = null; let rightResult = null; let leftBoundingBox = null; let rightBoundingBox = null; if (leftTree) { - // log.debug('APA01 Left tree before layout', leftTree.children[0]?.children[0]); const leftLayoutResult = layout.layout(leftTree); leftResult = leftLayoutResult.result; - log.debug('APA01 Left tree before layout', JSON.stringify(leftResult, null, 2)); leftBoundingBox = leftLayoutResult.boundingBox; } @@ -71,7 +61,6 @@ export function executeTidyTreeLayout( rightBoundingBox = rightLayoutResult.boundingBox; } - // Combine and position the trees const positionedNodes = combineAndPositionTrees( rootNode, leftResult, @@ -86,24 +75,11 @@ export function executeTidyTreeLayout( intersectionShift ); - log.debug( - `Tidy-tree layout completed: ${positionedNodes.length} nodes, ${positionedEdges.length} edges` - ); - if (leftBoundingBox || rightBoundingBox) { - log.debug( - `Left bounding box: ${leftBoundingBox ? `left=${leftBoundingBox.left}, right=${leftBoundingBox.right}, top=${leftBoundingBox.top}, bottom=${leftBoundingBox.bottom}` : 'none'}` - ); - log.debug( - `Right bounding box: ${rightBoundingBox ? `left=${rightBoundingBox.left}, right=${rightBoundingBox.right}, top=${rightBoundingBox.top}, bottom=${rightBoundingBox.bottom}` : 'none'}` - ); - } - resolve({ nodes: positionedNodes, edges: positionedEdges, }); } catch (error) { - log.error('Error in tidy-tree layout algorithm:', error); reject(error); } }); @@ -122,11 +98,9 @@ function convertToDualTreeFormat(data: LayoutData): { } { const { nodes, edges } = data; - // Create a map of nodes for quick lookup const nodeMap = new Map(); nodes.forEach((node) => nodeMap.set(node.id, node)); - // Build adjacency list to represent parent-child relationships const children = new Map(); const parents = new Map(); @@ -143,28 +117,21 @@ function convertToDualTreeFormat(data: LayoutData): { } }); - // Find root node (node with no parent) const rootNodeData = nodes.find((node) => !parents.has(node.id)); - if (!rootNodeData) { - // If no clear root, use the first node - if (nodes.length === 0) { - throw new Error('No nodes available to create tree'); - } - log.warn('No root node found, using first node as root'); + if (!rootNodeData && nodes.length === 0) { + throw new Error('No nodes available to create tree'); } - const actualRoot = rootNodeData || nodes[0]; + const actualRoot = rootNodeData ?? nodes[0]; - // Create root node const rootNode: TidyTreeNode = { id: actualRoot.id, - width: actualRoot.width || 100, - height: actualRoot.height || 50, + width: actualRoot.width ?? 100, + height: actualRoot.height ?? 50, _originalNode: actualRoot, }; - // Get root's children and split them alternately - const rootChildren = children.get(actualRoot.id) || []; + const rootChildren = children.get(actualRoot.id) ?? []; const leftChildren: string[] = []; const rightChildren: string[] = []; @@ -176,7 +143,6 @@ function convertToDualTreeFormat(data: LayoutData): { } }); - // Build left and right trees const leftTree = leftChildren.length > 0 ? buildSubTree(leftChildren, children, nodeMap) : null; const rightTree = @@ -194,10 +160,9 @@ function buildSubTree( children: Map, nodeMap: Map ): TidyTreeNode { - // Create a virtual root for this subtree const virtualRoot: TidyTreeNode = { id: `virtual-root-${Math.random()}`, - width: 1, // Minimal size for virtual root + width: 1, height: 1, children: rootChildren .map((childId) => nodeMap.get(childId)) @@ -217,21 +182,17 @@ function convertNodeToTidyTreeTransposed( children: Map, nodeMap: Map ): TidyTreeNode { - const childIds = children.get(node.id) || []; + const childIds = children.get(node.id) ?? []; const childNodes = childIds .map((childId) => nodeMap.get(childId)) .filter((child): child is Node => child !== undefined) .map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap)); - // Transpose width and height for horizontal layout - // When tree grows horizontally, the original width becomes the height in the layout - // and the original height becomes the width in the layout return { id: node.id, - width: node.height || 50, // Original height becomes layout width - height: node.width || 100, // Original width becomes layout height + width: node.height ?? 50, + height: node.width ?? 100, children: childNodes.length > 0 ? childNodes : undefined, - // Store original node data for later use _originalNode: node, }; } @@ -249,28 +210,17 @@ function combineAndPositionTrees( ): PositionedNode[] { const positionedNodes: PositionedNode[] = []; - log.debug('combineAndPositionTrees', { - leftResult, - rightResult, - }); - - // Calculate root position (center of the layout) const rootX = 0; const rootY = 0; - // Calculate spacing between trees - const treeSpacing = rootNode.width / 2 + 30; // Horizontal spacing from root to tree - - // First, collect node positions for each tree separately + const treeSpacing = rootNode.width / 2 + 30; const leftTreeNodes: PositionedNode[] = []; const rightTreeNodes: PositionedNode[] = []; - // Position left tree (grows to the left) if (leftResult?.children) { positionLeftTreeBidirectional(leftResult.children, leftTreeNodes, rootX - treeSpacing, rootY); } - // Position right tree (grows to the right) if (rightResult?.children) { positionRightTreeBidirectional( rightResult.children, @@ -280,22 +230,17 @@ function combineAndPositionTrees( ); } - // Calculate center points for each tree separately - // Only use first-level children (direct children of root) for centering let leftTreeCenterY = 0; let rightTreeCenterY = 0; if (leftTreeNodes.length > 0) { - // Filter to only first-level children (those closest to root on X axis) - // Find the X coordinate closest to root for left tree nodes const leftTreeXPositions = [...new Set(leftTreeNodes.map((node) => node.x))].sort( (a, b) => b - a - ); // Sort descending (closest to root first) - const firstLevelLeftX = leftTreeXPositions[0]; // X position closest to root + ); + const firstLevelLeftX = leftTreeXPositions[0]; const firstLevelLeftNodes = leftTreeNodes.filter((node) => node.x === firstLevelLeftX); if (firstLevelLeftNodes.length > 0) { - // Calculate bounding box considering node heights const leftMinY = Math.min( ...firstLevelLeftNodes.map((node) => node.y - (node.height ?? 50) / 2) ); @@ -307,16 +252,13 @@ function combineAndPositionTrees( } if (rightTreeNodes.length > 0) { - // Filter to only first-level children (those closest to root on X axis) - // Find the X coordinate closest to root for right tree nodes const rightTreeXPositions = [...new Set(rightTreeNodes.map((node) => node.x))].sort( (a, b) => a - b - ); // Sort ascending (closest to root first) - const firstLevelRightX = rightTreeXPositions[0]; // X position closest to root + ); + const firstLevelRightX = rightTreeXPositions[0]; const firstLevelRightNodes = rightTreeNodes.filter((node) => node.x === firstLevelRightX); if (firstLevelRightNodes.length > 0) { - // Calculate bounding box considering node heights const rightMinY = Math.min( ...firstLevelRightNodes.map((node) => node.y - (node.height ?? 50) / 2) ); @@ -327,22 +269,19 @@ function combineAndPositionTrees( } } - // Calculate different offsets for each tree to center them around the root const leftTreeOffset = -leftTreeCenterY; const rightTreeOffset = -rightTreeCenterY; - // Add the centered root positionedNodes.push({ id: String(rootNode.id), x: rootX, - y: rootY + 20, // Root stays at center + y: rootY + 20, section: 'root', - width: rootNode._originalNode?.width || rootNode.width, - height: rootNode._originalNode?.height || rootNode.height, + width: rootNode._originalNode?.width ?? rootNode.width, + height: rootNode._originalNode?.height ?? rootNode.height, originalNode: rootNode._originalNode, }); - // Add left tree nodes with their specific offset const leftTreeNodesWithOffset = leftTreeNodes.map((node) => ({ id: node.id, x: node.x - (node.width ?? 0) / 2, @@ -353,7 +292,6 @@ function combineAndPositionTrees( originalNode: node.originalNode, })); - // Add right tree nodes with their specific offset const rightTreeNodesWithOffset = rightTreeNodes.map((node) => ({ id: node.id, x: node.x + (node.width ?? 0) / 2, @@ -364,8 +302,6 @@ function combineAndPositionTrees( originalNode: node.originalNode, })); - // Add all nodes to the final result - // The tidy-tree algorithm should handle proper spacing, so no additional collision detection needed positionedNodes.push(...leftTreeNodesWithOffset); positionedNodes.push(...rightTreeNodesWithOffset); @@ -383,23 +319,18 @@ function positionLeftTreeBidirectional( offsetY: number ): void { nodes.forEach((node) => { - // For left tree: transpose the tidy-tree coordinates correctly - // Tidy-tree X (tree levels going down) becomes our Y position (vertical spacing) - // Tidy-tree Y (sibling spacing) becomes our X distance from root (grows left) - const distanceFromRoot = node.y ?? 0; // Horizontal distance from root (grows left) - const verticalPosition = node.x ?? 0; // Vertical position (tree levels) + const distanceFromRoot = node.y ?? 0; + const verticalPosition = node.x ?? 0; - // Get the original node dimensions directly from the stored original node const originalWidth = node._originalNode?.width ?? 100; const originalHeight = node._originalNode?.height ?? 50; - // Use the vertical position as calculated by the tidy-tree algorithm const adjustedY = offsetY + verticalPosition; positionedNodes.push({ id: String(node.id), - x: offsetX - distanceFromRoot, // Negative to grow left from root - y: adjustedY, // Vertical position based on tree level + x: offsetX - distanceFromRoot, + y: adjustedY, width: originalWidth, height: originalHeight, originalNode: node._originalNode, @@ -422,23 +353,18 @@ function positionRightTreeBidirectional( offsetY: number ): void { nodes.forEach((node) => { - // For right tree: transpose the tidy-tree coordinates correctly - // Tidy-tree X (tree levels going down) becomes our Y position (vertical spacing) - // Tidy-tree Y (sibling spacing) becomes our X distance from root (grows right) - const distanceFromRoot = node.y ?? 0; // Horizontal distance from root (grows right) - const verticalPosition = node.x ?? 0; // Vertical position (tree levels) + const distanceFromRoot = node.y ?? 0; + const verticalPosition = node.x ?? 0; - // Get the original node dimensions directly from the stored original node const originalWidth = node._originalNode?.width ?? 100; const originalHeight = node._originalNode?.height ?? 50; - // Use the vertical position as calculated by the tidy-tree algorithm const adjustedY = offsetY + verticalPosition; positionedNodes.push({ id: String(node.id), - x: offsetX + distanceFromRoot, // Positive to grow right from root - y: adjustedY, // Vertical position based on tree level + x: offsetX + distanceFromRoot, + y: adjustedY, width: originalWidth, height: originalHeight, originalNode: node._originalNode, @@ -472,17 +398,64 @@ function computeCircleEdgeIntersection( return lineStart; } - // Normalize the direction vector from Start to End const nx = dx / length; const ny = dy / length; - // Start at the circle center and go backwards by the radius along the direction vector return { x: circle.x - nx * radius, y: circle.y - ny * radius, }; } +/** + * Calculate intersection point of a line with a rectangle + * This is a simplified version that we'll use instead of importing from mermaid + */ +function intersection( + node: { x: number; y: number; width?: number; height?: number }, + point1: { x: number; y: number }, + point2: { x: number; y: number } +): { x: number; y: number } { + const nodeWidth = node.width ?? 100; + const nodeHeight = node.height ?? 50; + + const centerX = node.x; + const centerY = node.y; + + const dx = point2.x - point1.x; + const dy = point2.y - point1.y; + + if (dx === 0 && dy === 0) { + return { x: centerX, y: centerY }; + } + + const halfWidth = nodeWidth / 2; + const halfHeight = nodeHeight / 2; + + let intersectionX = centerX; + let intersectionY = centerY; + + if (Math.abs(dx) > Math.abs(dy)) { + if (dx > 0) { + intersectionX = centerX + halfWidth; + intersectionY = centerY + (halfWidth * dy) / dx; + } else { + intersectionX = centerX - halfWidth; + intersectionY = centerY - (halfWidth * dy) / dx; + } + } else { + if (dy > 0) { + intersectionY = centerY + halfHeight; + intersectionX = centerX + (halfHeight * dx) / dy; + } else { + intersectionY = centerY - halfHeight; + intersectionX = centerX - (halfHeight * dx) / dy; + } + } + + return { x: intersectionX, y: intersectionY }; +} + /** * Calculate edge positions based on positioned nodes * Now includes tree membership and node dimensions for precise edge calculations @@ -503,8 +476,6 @@ function calculateEdgePositions( const targetNode = nodeInfo.get(edge.end ?? ''); if (!sourceNode || !targetNode) { - const missingNode = !sourceNode ? 'Source' : 'Target'; - log.error(`APA01 ${missingNode} node not found for edge`, edge); return { id: edge.id, source: edge.start ?? '', @@ -535,7 +506,6 @@ function calculateEdgePositions( targetNode.originalNode?.shape ?? '' ); - // Initial intersection approximation let startPos = isSourceRound ? computeCircleEdgeIntersection( { @@ -591,7 +561,6 @@ function calculateEdgePositions( points.push(endPos); - // Recalculate source intersection const secondPoint = points.length > 1 ? points[1] : targetCenter; startPos = isSourceRound ? computeCircleEdgeIntersection( @@ -607,7 +576,6 @@ function calculateEdgePositions( : intersection(sourceNode, secondPoint, sourceCenter); points[0] = startPos; - // Recalculate target intersection const secondLastPoint = points.length > 1 ? points[points.length - 2] : sourceCenter; endPos = isTargetRound ? computeCircleEdgeIntersection( @@ -634,10 +602,8 @@ function calculateEdgePositions( endX: endPos.x, endY: endPos.y, points, - // Include tree membership information sourceSection: sourceNode?.section, targetSection: targetNode?.section, - // Include node dimensions for precise edge point calculations sourceWidth: sourceNode?.width, sourceHeight: sourceNode?.height, targetWidth: targetNode?.width, diff --git a/packages/mermaid-layout-tidy-tree/src/layouts.ts b/packages/mermaid-layout-tidy-tree/src/layouts.ts new file mode 100644 index 000000000..ee9870828 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layouts.ts @@ -0,0 +1,13 @@ +import type { LayoutLoaderDefinition } from 'mermaid'; + +const loader = async () => await import(`./render.js`); + +const layouts: LayoutLoaderDefinition[] = [ + { + name: 'tidy-tree', + loader, + algorithm: 'tidy-tree', + }, +]; + +export default layouts; diff --git a/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.ts b/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.ts new file mode 100644 index 000000000..248b5c05f --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.ts @@ -0,0 +1,18 @@ +declare module 'non-layered-tidy-tree-layout' { + export class BoundingBox { + constructor(gap: number, bottomPadding: number); + } + + export class Layout { + constructor(boundingBox: BoundingBox); + layout(data: any): { + result: any; + boundingBox: { + left: number; + right: number; + top: number; + bottom: number; + }; + }; + } +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/render.ts b/packages/mermaid-layout-tidy-tree/src/render.ts similarity index 72% rename from packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/render.ts rename to packages/mermaid-layout-tidy-tree/src/render.ts index 9505f63a6..be4c7270a 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/render.ts +++ b/packages/mermaid-layout-tidy-tree/src/render.ts @@ -7,8 +7,8 @@ interface NodeWithPosition { y?: number; width?: number; height?: number; - domId?: any; // SVG element reference - [key: string]: any; // Allow additional properties from original node + domId?: any; + [key: string]: any; } /** @@ -41,23 +41,19 @@ export const render = async ( const nodeDb: Record = {}; const clusterDb: Record = {}; - // Insert markers for edges const element = svg.select('g'); insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId); - // Create container groups 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'); - // Step 1: Insert nodes into DOM to get their actual dimensions log.debug('Inserting nodes into DOM for dimension calculation'); await Promise.all( data4Layout.nodes.map(async (node) => { if (node.isGroup) { - // Handle subgraphs/clusters const clusterNode: NodeWithPosition = { ...node, id: node.id, @@ -67,10 +63,8 @@ export const render = async ( clusterDb[node.id] = clusterNode; nodeDb[node.id] = clusterNode; - // Insert cluster to get dimensions await insertCluster(subGraphsEl, node); } else { - // Handle regular nodes const nodeWithPosition: NodeWithPosition = { ...node, id: node.id, @@ -79,13 +73,11 @@ export const render = async ( }; nodeDb[node.id] = nodeWithPosition; - // Insert node to get actual dimensions const nodeEl = await insertNode(nodes, node, { config: data4Layout.config, dir: data4Layout.direction || 'TB', }); - // Get the actual bounding box after insertion const boundingBox = nodeEl.node()!.getBBox(); nodeWithPosition.width = boundingBox.width; nodeWithPosition.height = boundingBox.height; @@ -96,38 +88,29 @@ export const render = async ( }) ); - // Step 2: Run the bidirectional tidy-tree layout algorithm log.debug('Running bidirectional tidy-tree layout algorithm'); - // Update the layout data with actual dimensions 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, + width: nodeWithDimensions.width ?? node.width ?? 100, + height: nodeWithDimensions.height ?? node.height ?? 50, }; }), }; const layoutResult = await executeTidyTreeLayout(updatedLayoutData, data4Layout.config); - // Step 3: Position the nodes based on bidirectional layout results log.debug('Positioning nodes based on bidirectional layout results'); layoutResult.nodes.forEach((positionedNode) => { const node = nodeDb[positionedNode.id]; if (node?.domId) { - // Position the node at the calculated coordinates from bidirectional layout - // The layout algorithm has already calculated positions for: - // - Root node at center (0, 0) - // - Left tree nodes with negative x coordinates (growing left) - // - Right tree nodes with positive x coordinates (growing right) node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`); - // Store the final position node.x = positionedNode.x; node.y = positionedNode.y; @@ -135,34 +118,24 @@ export const render = async ( } }); - // Step 4: Insert and position edges log.debug('Inserting and positioning edges'); await Promise.all( data4Layout.edges.map(async (edge) => { - // Insert edge label first await insertEdgeLabel(edgeLabels, edge); - // Get start and end nodes - const startNode = nodeDb[edge.start || '']; - const endNode = nodeDb[edge.end || '']; + const startNode = nodeDb[edge.start ?? '']; + const endNode = nodeDb[edge.end ?? '']; if (startNode && endNode) { - // Find the positioned edge data const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); if (positionedEdge) { log.debug('APA01 positionedEdge', positionedEdge); - // Create edge path with positioned coordinates const edgeWithPath = { ...edge, points: positionedEdge.points, - // points: [ - // { x: positionedEdge.startX, y: positionedEdge.startY }, - // { x: positionedEdge.endX, y: positionedEdge.endY }, - // ], }; - // Insert the edge path const paths = insertEdge( edgePaths, edgeWithPath, @@ -173,15 +146,13 @@ export const render = async ( data4Layout.diagramId ); - // Position the edge label positionEdgeLabel(edgeWithPath, paths); } else { - // Fallback: create a simple straight line between nodes const edgeWithPath = { ...edge, points: [ - { x: startNode.x || 0, y: startNode.y || 0 }, - { x: endNode.x || 0, y: endNode.y || 0 }, + { x: startNode.x ?? 0, y: startNode.y ?? 0 }, + { x: endNode.x ?? 0, y: endNode.y ?? 0 }, ], }; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/types.ts b/packages/mermaid-layout-tidy-tree/src/types.ts similarity index 64% rename from packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/types.ts rename to packages/mermaid-layout-tidy-tree/src/types.ts index e1a02746c..d38b68793 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/types.ts +++ b/packages/mermaid-layout-tidy-tree/src/types.ts @@ -1,4 +1,7 @@ -import type { Node } from '../../types.js'; +import type { LayoutData } from 'mermaid'; + +type Node = LayoutData['nodes'][number]; +type Edge = LayoutData['edges'][number]; /** * Positioned node after layout calculation @@ -7,14 +10,11 @@ export interface PositionedNode { id: string; x: number; y: number; - // Tree membership information for edge calculations section?: 'root' | 'left' | 'right'; - // Original node dimensions for edge point calculations width?: number; height?: number; - // Reference to original node for additional data originalNode?: Node; - [key: string]: unknown; // Allow additional properties + [key: string]: unknown; } /** @@ -30,15 +30,13 @@ export interface PositionedEdge { midY: number; endX: number; endY: number; - // Tree membership information for edge styling/routing sourceSection?: 'root' | 'left' | 'right'; targetSection?: 'root' | 'left' | 'right'; - // Node dimensions for precise edge point calculations sourceWidth?: number; sourceHeight?: number; targetWidth?: number; targetHeight?: number; - [key: string]: unknown; // Allow additional properties + [key: string]: unknown; } /** @@ -59,13 +57,15 @@ export interface TidyTreeNode { x?: number; y?: number; children?: TidyTreeNode[]; - _originalNode?: Node; // Store reference to original node data + _originalNode?: Node; } /** * Tidy-tree layout configuration */ export interface TidyTreeLayoutConfig { - gap: number; // Horizontal gap between nodes - bottomPadding: number; // Vertical gap between levels + gap: number; + bottomPadding: number; } + +export type { Node, Edge }; diff --git a/packages/mermaid-layout-tidy-tree/tsconfig.json b/packages/mermaid-layout-tidy-tree/tsconfig.json new file mode 100644 index 000000000..0d701cede --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["vitest/importMeta", "vitest/globals"] + }, + "include": ["./src/**/*.ts"], + "typeRoots": ["./src/types"] +} diff --git a/packages/mermaid/src/rendering-util/render.ts b/packages/mermaid/src/rendering-util/render.ts index f2f70b53d..1c822ccf2 100644 --- a/packages/mermaid/src/rendering-util/render.ts +++ b/packages/mermaid/src/rendering-util/render.ts @@ -45,7 +45,7 @@ const registerDefaultLayoutLoaders = () => { }, { name: 'tidy-tree', - loader: async () => await import('./layout-algorithms/tidy-tree/index.js'), + loader: async () => await import('../../../mermaid-layout-tidy-tree/src/index.js'), }, { name: 'elk', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1a7ae90d..d2867adb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,21 @@ importers: mermaid: specifier: workspace:^ version: link:../mermaid + packages/mermaid-layout-tidy-tree: + dependencies: + d3: + specifier: ^7.9.0 + version: 7.9.0 + non-layered-tidy-tree-layout: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 + mermaid: + specifier: workspace:^ + version: link:../mermaid packages/mermaid-zenuml: dependencies: