extract tidy-tree layout into separate package

on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
This commit is contained in:
darshanr0107
2025-08-01 13:36:35 +05:30
parent 3677abe9e5
commit a92c3bb251
12 changed files with 298 additions and 218 deletions

View File

@@ -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
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import tidyTreeLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-tidy-tree@0/dist/mermaid-layout-tidy-tree.esm.min.mjs';
mermaid.registerLayoutLoaders(tidyTreeLayouts);
</script>
## 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]

View File

@@ -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"
]
}

View File

@@ -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';

View File

@@ -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);
});
});
});

View File

@@ -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<LayoutResult> {
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<string, Node>();
nodes.forEach((node) => nodeMap.set(node.id, node));
// Build adjacency list to represent parent-child relationships
const children = new Map<string, string[]>();
const parents = new Map<string, string>();
@@ -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<string, string[]>,
nodeMap: Map<string, Node>
): 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<string, string[]>,
nodeMap: Map<string, Node>
): 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,

View File

@@ -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;

View File

@@ -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;
};
};
}
}

View File

@@ -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<string, NodeWithPosition> = {};
const clusterDb: Record<string, any> = {};
// 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 },
],
};

View File

@@ -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 };

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["vitest/importMeta", "vitest/globals"]
},
"include": ["./src/**/*.ts"],
"typeRoots": ["./src/types"]
}

View File

@@ -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',

15
pnpm-lock.yaml generated
View File

@@ -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: