mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-15 06:19:24 +02:00
extract tidy-tree layout into separate package
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
This commit is contained in:
61
packages/mermaid-layout-tidy-tree/README.md
Normal file
61
packages/mermaid-layout-tidy-tree/README.md
Normal 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]
|
46
packages/mermaid-layout-tidy-tree/package.json
Normal file
46
packages/mermaid-layout-tidy-tree/package.json
Normal 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"
|
||||
]
|
||||
}
|
@@ -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';
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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,
|
13
packages/mermaid-layout-tidy-tree/src/layouts.ts
Normal file
13
packages/mermaid-layout-tidy-tree/src/layouts.ts
Normal 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;
|
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
@@ -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 };
|
10
packages/mermaid-layout-tidy-tree/tsconfig.json
Normal file
10
packages/mermaid-layout-tidy-tree/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["vitest/importMeta", "vitest/globals"]
|
||||
},
|
||||
"include": ["./src/**/*.ts"],
|
||||
"typeRoots": ["./src/types"]
|
||||
}
|
@@ -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
15
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
Reference in New Issue
Block a user