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: