Tidy-tree WIP

This commit is contained in:
Knut Sveidqvist
2025-06-11 18:04:21 +02:00
parent eadb343292
commit d2463f41b5
9 changed files with 769 additions and 700 deletions

View File

@@ -116,6 +116,7 @@
Tools
Pen and paper
Mermaid
Third
</pre>
<pre id="diagram4" class="mermaid">
---

131
instructions.md Normal file
View File

@@ -0,0 +1,131 @@
Please help me to implement the tidy-tree algorithm.
* I have added a placeholder with the correct signature in `packages/mermaid/src/rendering-util/layout-algorithms/tidy-tree/`.
Replace the cytoscape layout with one using tidy-tree.
This is the API for tidy-tree:
```
# non-layered-tidy-tree-layout
Draw non-layered tidy trees in linear time.
> This a JavaScript port from the project [cwi-swat/non-layered-tidy-trees](https://github.com/cwi-swat/non-layered-tidy-trees), which is written in Java. The algorithm used in that project is from the paper by _A.J. van der Ploeg_, [Drawing Non-layered Tidy Trees in Linear Time](http://oai.cwi.nl/oai/asset/21856/21856B.pdf). There is another JavaScript port from that project [d3-flextree](https://github.com/Klortho/d3-flextree), which depends on _d3-hierarchy_. This project is dependency free.
## Getting started
### Installation
```
npm install non-layered-tidy-tree-layout
```
Or
```
yarn add non-layered-tidy-tree-layout
```
There's also a built verison: `dist/non-layered-tidy-tree-layout.js` for use with browser `<script>` tag, or as a Javascript module.
### Usage
```js
import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout'
// BoundingBox(gap, bottomPadding)
const bb = new BoundingBox(10, 20)
const layout = new Layout(bb)
const treeData = {
id: 0,
width: 40,
height: 40,
children: [
{
id: 1,
width: 40,
height: 40,
children: [{ id: 6, width: 400, height: 40 }]
},
{ id: 2, width: 40, height: 40 },
{ id: 3, width: 40, height: 40 },
{ id: 4, width: 40, height: 40 },
{ id: 5, width: 40, height: 80 }
]
}
const { result, boundingBox } = layout.layout(treeData)
// result:
// {
// id: 0,
// x: 300,
// y: 0,
// width: 40,
// height: 40,
// children: [
// {
// id: 1,
// x: 185,
// y: 60,
// width: 40,
// height: 40,
// children: [
// { id: 6, x: 5, y: 120, width: 400, height: 40 }
// ]
// },
// { id: 2, x: 242.5, y: 60, width: 40, height: 40 },
// { id: 3, x: 300, y: 60, width: 40, height: 40 },
// { id: 4, x: 357.5, y: 60, width: 40, height: 40 },
// { id: 5, x: 415, y: 60, width: 40, height: 80 }
// ]
// }
//
// boundingBox:
// {
// left: 5,
// right: 455,
// top: 0,
// bottom: 160
// }
```
The method `Layout.layout` modifies `treeData` inplace. It returns an object like `{ result: treeData, boundingBox: {left: num, right: num, top: num, bottom: num} }`. `result` is the same object `treeData` with calculated coordinates, `boundingBox` are the coordinates for the whole tree:
![](./screenshots/1.png)
The red dashed lines are the bounding boxes for each node. `Layout.layout()` produces coordinates to draw nodes, which are the grey boxes with black border.
The library also provides a class `Tree` and a method `layout`.
```js
/**
* Constructor for Tree.
* @param {number} width - width of bounding box
* @param {number} height - height of bounding box
* @param {number} y - veritcal coordinate of bounding box
* @param {array} children - a list of Tree instances
*/
new Tree(width, height, y, children)
/**
* Calculate x, y coordindates and assign them to tree.
* @param {Object} tree - a Tree object
*/
layout(tree)
```
In case your data structure are not the same as provided by the example above, you can refer to `src/helpers.js` to implement a `Layout` class that converts your data to a `Tree`, then call `layout` to calculate the coordinates for drawing.
## License
[MIT](./LICENSE)
## Changelog
### [2.0.1]
- Fixed bounding box calculation in `Layout.getSize` and `Layout.assignLayout` and `Layout.layout`
### [2.0.0]
- Added `Layout.layout`
- Removed `Layout.layoutTreeData`
### [1.0.0]
- Added `Layout`, `BoundingBox`, `layout`, `Tree`
```

View File

@@ -1,281 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
addNodes,
addEdges,
extractPositionedNodes,
extractPositionedEdges,
} from './cytoscape-setup.js';
import type { Node, Edge } from '../../types.js';
// Mock cytoscape
const mockCy = {
add: vi.fn(),
nodes: vi.fn(),
edges: vi.fn(),
};
vi.mock('cytoscape', () => {
const mockCytoscape = vi.fn(() => mockCy) as any;
mockCytoscape.use = vi.fn();
return {
default: mockCytoscape,
};
});
vi.mock('cytoscape-cose-bilkent', () => ({
default: vi.fn(),
}));
vi.mock('d3', () => ({
select: vi.fn(() => ({
append: vi.fn(() => ({
attr: vi.fn(() => ({
attr: vi.fn(() => ({
remove: vi.fn(),
})),
})),
})),
})),
}));
describe('Cytoscape Setup', () => {
let mockNodes: Node[];
let mockEdges: Edge[];
beforeEach(() => {
vi.clearAllMocks();
mockNodes = [
{
id: '1',
label: 'Root',
isGroup: false,
shape: 'rect',
width: 100,
height: 50,
padding: 10,
x: 100,
y: 100,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: '2',
label: 'Child 1',
isGroup: false,
shape: 'rect',
width: 80,
height: 40,
padding: 10,
x: 150,
y: 150,
cssClasses: '',
cssStyles: [],
look: 'default',
},
];
mockEdges = [
{
id: '1_2',
start: '1',
end: '2',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
];
});
describe('addNodes', () => {
it('should add nodes to cytoscape', () => {
addNodes([mockNodes[0]], mockCy as unknown as any);
expect(mockCy.add).toHaveBeenCalledWith({
group: 'nodes',
data: {
id: '1',
labelText: 'Root',
height: 50,
width: 100,
padding: 10,
isGroup: false,
shape: 'rect',
cssClasses: '',
cssStyles: [],
look: 'default',
},
position: {
x: 100,
y: 100,
},
});
});
it('should add multiple nodes to cytoscape', () => {
addNodes(mockNodes, mockCy as unknown as any);
expect(mockCy.add).toHaveBeenCalledTimes(2);
expect(mockCy.add).toHaveBeenCalledWith({
group: 'nodes',
data: {
id: '1',
labelText: 'Root',
height: 50,
width: 100,
padding: 10,
isGroup: false,
shape: 'rect',
cssClasses: '',
cssStyles: [],
look: 'default',
},
position: {
x: 100,
y: 100,
},
});
expect(mockCy.add).toHaveBeenCalledWith({
group: 'nodes',
data: {
id: '2',
labelText: 'Child 1',
height: 40,
width: 80,
padding: 10,
isGroup: false,
shape: 'rect',
cssClasses: '',
cssStyles: [],
look: 'default',
},
position: {
x: 150,
y: 150,
},
});
});
});
describe('addEdges', () => {
it('should add edges to cytoscape', () => {
addEdges(mockEdges, mockCy as unknown as any);
expect(mockCy.add).toHaveBeenCalledWith({
group: 'edges',
data: {
id: '1_2',
source: '1',
target: '2',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
});
});
});
describe('extractPositionedNodes', () => {
it('should extract positioned nodes from cytoscape', () => {
const mockCytoscapeNodes = [
{
data: () => ({
id: '1',
labelText: 'Root',
width: 100,
height: 50,
padding: 10,
isGroup: false,
shape: 'rect',
}),
position: () => ({ x: 100, y: 100 }),
},
{
data: () => ({
id: '2',
labelText: 'Child 1',
width: 80,
height: 40,
padding: 10,
isGroup: false,
shape: 'rect',
}),
position: () => ({ x: 150, y: 150 }),
},
];
mockCy.nodes.mockReturnValue({
map: (fn: unknown) => mockCytoscapeNodes.map(fn as any),
});
const result = extractPositionedNodes(mockCy as unknown as any);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: '1',
x: 100,
y: 100,
labelText: 'Root',
width: 100,
height: 50,
padding: 10,
isGroup: false,
shape: 'rect',
});
});
});
describe('extractPositionedEdges', () => {
it('should extract positioned edges from cytoscape', () => {
const mockCytoscapeEdges = [
{
data: () => ({
id: '1_2',
source: '1',
target: '2',
type: 'edge',
}),
_private: {
rscratch: {
startX: 100,
startY: 100,
midX: 125,
midY: 125,
endX: 150,
endY: 150,
},
},
},
];
mockCy.edges.mockReturnValue({
map: (fn: unknown) => mockCytoscapeEdges.map(fn as any),
});
const result = extractPositionedEdges(mockCy as unknown as any);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: '1_2',
source: '1',
target: '2',
type: 'edge',
startX: 100,
startY: 100,
midX: 125,
midY: 125,
endX: 150,
endY: 150,
});
});
});
});

View File

@@ -1,207 +0,0 @@
import cytoscape from 'cytoscape';
import coseBilkent from 'cytoscape-cose-bilkent';
import { select } from 'd3';
import { log } from '../../../logger.js';
import type { LayoutData, Node, Edge } from '../../types.js';
import type { CytoscapeLayoutConfig, PositionedNode, PositionedEdge } from './types.js';
// Inject the layout algorithm into cytoscape
cytoscape.use(coseBilkent);
/**
* Declare module augmentation for cytoscape edge types
*/
declare module 'cytoscape' {
interface EdgeSingular {
_private: {
bodyBounds: unknown;
rscratch: {
startX: number;
startY: number;
midX: number;
midY: number;
endX: number;
endY: number;
};
};
}
}
/**
* Add nodes to cytoscape instance from provided node array
* This function processes only the nodes provided in the data structure
* @param nodes - Array of nodes to add
* @param cy - The cytoscape instance
*/
export function addNodes(nodes: Node[], cy: cytoscape.Core): void {
nodes.forEach((node) => {
const nodeData: Record<string, unknown> = {
id: node.id,
labelText: node.label,
height: node.height,
width: node.width,
padding: node.padding ?? 0,
};
// Add any additional properties from the node
Object.keys(node).forEach((key) => {
if (!['id', 'label', 'height', 'width', 'padding', 'x', 'y'].includes(key)) {
nodeData[key] = (node as unknown as Record<string, unknown>)[key];
}
});
cy.add({
group: 'nodes',
data: nodeData,
position: {
x: node.x ?? 0,
y: node.y ?? 0,
},
});
});
}
/**
* Add edges to cytoscape instance from provided edge array
* This function processes only the edges provided in the data structure
* @param edges - Array of edges to add
* @param cy - The cytoscape instance
*/
export function addEdges(edges: Edge[], cy: cytoscape.Core): void {
edges.forEach((edge) => {
const edgeData: Record<string, unknown> = {
id: edge.id,
source: edge.start,
target: edge.end,
};
// Add any additional properties from the edge
Object.keys(edge).forEach((key) => {
if (!['id', 'start', 'end'].includes(key)) {
edgeData[key] = (edge as unknown as Record<string, unknown>)[key];
}
});
cy.add({
group: 'edges',
data: edgeData,
});
});
}
/**
* Create and configure cytoscape instance
* @param data - Layout data containing nodes and edges
* @returns Promise resolving to configured cytoscape instance
*/
export function createCytoscapeInstance(data: LayoutData): Promise<cytoscape.Core> {
return new Promise((resolve) => {
// Add temporary render element
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
const cy = cytoscape({
container: document.getElementById('cy'), // container to render in
style: [
{
selector: 'edge',
style: {
'curve-style': 'bezier',
},
},
],
});
// Remove element after layout
renderEl.remove();
// Add all nodes and edges to cytoscape using the generic functions
addNodes(data.nodes, cy);
addEdges(data.edges, cy);
// Make cytoscape care about the dimensions of the nodes
cy.nodes().forEach(function (n) {
n.layoutDimensions = () => {
const nodeData = n.data();
return { w: nodeData.width, h: nodeData.height };
};
});
// Configure and run the cose-bilkent layout
const layoutConfig: CytoscapeLayoutConfig = {
name: 'cose-bilkent',
// @ts-ignore Types for cose-bilkent are not correct?
quality: 'proof',
styleEnabled: false,
animate: false,
};
cy.layout(layoutConfig).run();
cy.ready((e) => {
log.info('Cytoscape ready', e);
resolve(cy);
});
});
}
/**
* Extract positioned nodes from cytoscape instance
* @param cy - The cytoscape instance after layout
* @returns Array of positioned nodes
*/
export function extractPositionedNodes(cy: cytoscape.Core): PositionedNode[] {
return cy.nodes().map((node) => {
const data = node.data();
const position = node.position();
// Create a positioned node with all original data plus position
const positionedNode: PositionedNode = {
id: data.id,
x: position.x,
y: position.y,
};
// Add all other properties from the original data
Object.keys(data).forEach((key) => {
if (key !== 'id') {
positionedNode[key] = data[key];
}
});
return positionedNode;
});
}
/**
* Extract positioned edges from cytoscape instance
* @param cy - The cytoscape instance after layout
* @returns Array of positioned edges
*/
export function extractPositionedEdges(cy: cytoscape.Core): PositionedEdge[] {
return cy.edges().map((edge) => {
const data = edge.data();
const rscratch = edge._private.rscratch;
// Create a positioned edge with all original data plus position
const positionedEdge: PositionedEdge = {
id: data.id,
source: data.source,
target: data.target,
startX: rscratch.startX,
startY: rscratch.startY,
midX: rscratch.midX,
midY: rscratch.midY,
endX: rscratch.endX,
endY: rscratch.endY,
};
// Add all other properties from the original data
Object.keys(data).forEach((key) => {
if (!['id', 'source', 'target'].includes(key)) {
positionedEdge[key] = data[key];
}
});
return positionedEdge;
});
}

View File

@@ -1,25 +1,49 @@
import { render as renderWithCoseBilkent } from './render.js';
import { render as renderWithTidyTree } from './render.js';
/**
* Cose-Bilkent Layout Algorithm for Generic Diagrams
* Bidirectional Tidy-Tree Layout Algorithm for Generic Diagrams
*
* This module provides a layout algorithm implementation using Cytoscape
* with the cose-bilkent algorithm for positioning nodes and edges.
* This module provides a layout algorithm implementation using the
* non-layered-tidy-tree-layout algorithm for positioning nodes and edges
* in tree structures with a bidirectional approach.
*
* The 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.
*
* The algorithm follows the unified rendering pattern and can be used
* by any diagram type that provides compatible LayoutData.
*/
/**
* Render function for the cose-bilkent layout algorithm
* Render function for the bidirectional tidy-tree layout algorithm
*
* This function follows the unified rendering pattern used by all layout algorithms.
* It takes LayoutData, inserts nodes into DOM, runs the cose-bilkent layout algorithm,
* It takes LayoutData, inserts nodes into DOM, runs the bidirectional tidy-tree layout algorithm,
* and renders the positioned elements to the SVG.
*
* Features:
* - Alternates root children between left and right trees
* - Left tree grows horizontally to the left (rotated 90° counterclockwise)
* - Right tree grows horizontally to the right (rotated 90° clockwise)
* - Uses tidy-tree algorithm for optimal spacing within each tree
* - Creates symmetric, balanced layouts
* - Maintains proper edge connections between all nodes
*
* Layout Structure:
* ```
* [Child 3] ← [Child 1] ← [Root] → [Child 2] → [Child 4]
* ↓ ↓ ↓ ↓
* [GrandChild] [GrandChild] [GrandChild] [GrandChild]
* ```
*
* @param layoutData - Layout data containing nodes, edges, and configuration
* @param svg - SVG element to render to
* @param helpers - Internal helper functions for rendering
* @param options - Rendering options
*/
export const render = renderWithCoseBilkent;
export const render = renderWithTidyTree;

View File

@@ -1,160 +1,190 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock cytoscape and cytoscape-cose-bilkent before importing the modules
vi.mock('cytoscape', () => {
const mockCy = {
add: vi.fn(),
nodes: vi.fn(() => ({
forEach: vi.fn(),
map: vi.fn((fn) => [
fn({
data: () => ({
id: '1',
nodeId: '1',
labelText: 'Root',
level: 0,
type: 0,
width: 100,
height: 50,
padding: 10,
}),
position: () => ({ x: 100, y: 100 }),
}),
]),
})),
edges: vi.fn(() => ({
map: vi.fn((fn) => [
fn({
data: () => ({
id: '1_2',
source: '1',
target: '2',
depth: 0,
}),
_private: {
rscratch: {
startX: 100,
startY: 100,
midX: 150,
midY: 150,
endX: 200,
endY: 200,
},
},
}),
]),
})),
layout: vi.fn(() => ({
run: vi.fn(),
})),
ready: vi.fn((callback) => callback({})),
};
// Mock non-layered-tidy-tree-layout
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 };
const mockCytoscape = vi.fn(() => mockCy);
mockCytoscape.use = vi.fn();
// Set positions for the virtual root (if it exists)
if (result.id && result.id.toString().startsWith('virtual-root')) {
result.x = 0;
result.y = 0;
} else {
result.x = 100;
result.y = 50;
}
// Set positions for children if they exist
if (result.children) {
result.children.forEach((child, index) => {
child.x = 50 + index * 100;
child.y = 100;
// Recursively position grandchildren
if (child.children) {
child.children.forEach((grandchild, gIndex) => {
grandchild.x = 25 + gIndex * 50;
grandchild.y = 200;
});
}
});
}
return {
default: mockCytoscape,
result,
boundingBox: {
left: 0,
right: 200,
top: 0,
bottom: 250,
},
};
});
vi.mock('cytoscape-cose-bilkent', () => ({
default: vi.fn(),
}));
vi.mock('d3', () => ({
select: vi.fn(() => ({
append: vi.fn(() => ({
attr: vi.fn(() => ({
attr: vi.fn(() => ({
remove: vi.fn(),
})),
})),
})),
}),
})),
}));
// Import modules after mocks
import { layout, validateLayoutData } from './index.js';
import type { MindmapLayoutData, LayoutResult } from './types.js';
import type { MindmapNode } from '../../../diagrams/mindmap/mindmapTypes.js';
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';
describe('Cose-Bilkent Layout Algorithm', () => {
describe('Tidy-Tree Layout Algorithm', () => {
let mockConfig: MermaidConfig;
let mockRootNode: MindmapNode;
let mockLayoutData: MindmapLayoutData;
let mockLayoutData: LayoutData;
beforeEach(() => {
mockConfig = {
mindmap: {
layoutAlgorithm: 'cose-bilkent',
padding: 10,
maxNodeWidth: 200,
useMaxWidth: true,
},
theme: 'default',
} as MermaidConfig;
mockRootNode = {
id: 1,
nodeId: '1',
level: 0,
descr: 'Root',
type: 0,
width: 100,
height: 50,
padding: 10,
x: 0,
y: 0,
children: [
{
id: 2,
nodeId: '2',
level: 1,
descr: 'Child 1',
type: 0,
width: 80,
height: 40,
padding: 10,
x: 0,
y: 0,
},
],
} as MindmapNode;
mockLayoutData = {
nodes: [
{
id: '1',
nodeId: '1',
level: 0,
descr: 'Root',
type: 0,
id: 'root',
label: 'Root',
isGroup: false,
shape: 'rect',
width: 100,
height: 50,
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: '2',
nodeId: '2',
level: 1,
descr: 'Child 1',
type: 0,
id: 'child1',
label: 'Child 1',
isGroup: false,
shape: 'rect',
width: 80,
height: 40,
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: 'child2',
label: 'Child 2',
isGroup: false,
shape: 'rect',
width: 80,
height: 40,
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: 'child3',
label: 'Child 3',
isGroup: false,
shape: 'rect',
width: 80,
height: 40,
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: 'child4',
label: 'Child 4',
isGroup: false,
shape: 'rect',
width: 80,
height: 40,
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
],
edges: [
{
id: '1_2',
source: '1',
target: '2',
depth: 0,
id: 'root_child1',
start: 'root',
end: 'child1',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
{
id: 'root_child2',
start: 'root',
end: 'child2',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
{
id: 'root_child3',
start: 'root',
end: 'child3',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
{
id: 'root_child4',
start: 'root',
end: 'child4',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
],
config: mockConfig,
rootNode: mockRootNode,
direction: 'TB',
type: 'test',
diagramId: 'test-diagram',
markers: [],
};
});
@@ -167,11 +197,6 @@ describe('Cose-Bilkent Layout Algorithm', () => {
expect(() => validateLayoutData(null as any)).toThrow('Layout data is required');
});
it('should throw error for missing root node', () => {
const invalidData = { ...mockLayoutData, rootNode: null as any };
expect(() => validateLayoutData(invalidData)).toThrow('Root node is required');
});
it('should throw error for missing config', () => {
const invalidData = { ...mockLayoutData, config: null as any };
expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required');
@@ -188,9 +213,9 @@ describe('Cose-Bilkent Layout Algorithm', () => {
});
});
describe('layout function', () => {
describe('executeTidyTreeLayout function', () => {
it('should execute layout algorithm successfully', async () => {
const result: LayoutResult = await layout(mockLayoutData, mockConfig);
const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toBeDefined();
@@ -200,7 +225,7 @@ describe('Cose-Bilkent Layout Algorithm', () => {
});
it('should return positioned nodes with coordinates', async () => {
const result: LayoutResult = await layout(mockLayoutData, mockConfig);
const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData, mockConfig);
expect(result.nodes.length).toBeGreaterThan(0);
result.nodes.forEach((node) => {
@@ -212,7 +237,7 @@ describe('Cose-Bilkent Layout Algorithm', () => {
});
it('should return positioned edges with coordinates', async () => {
const result: LayoutResult = await layout(mockLayoutData, mockConfig);
const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData, mockConfig);
expect(result.edges.length).toBeGreaterThan(0);
result.edges.forEach((edge) => {
@@ -225,26 +250,77 @@ describe('Cose-Bilkent Layout Algorithm', () => {
});
});
it('should handle empty mindmap data gracefully', async () => {
const emptyData: MindmapLayoutData = {
it('should handle empty layout data gracefully', async () => {
const emptyData: LayoutData = {
...mockLayoutData,
nodes: [],
edges: [],
config: mockConfig,
rootNode: mockRootNode,
};
const result: LayoutResult = await layout(emptyData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toBeDefined();
expect(result.edges).toBeDefined();
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.edges)).toBe(true);
await expect(executeTidyTreeLayout(emptyData, mockConfig)).rejects.toThrow(
'No nodes found in layout data'
);
});
it('should throw error for invalid data', async () => {
const invalidData = { ...mockLayoutData, rootNode: null as any };
it('should throw error for missing nodes', async () => {
const invalidData = { ...mockLayoutData, nodes: [] };
await expect(layout(invalidData, mockConfig)).rejects.toThrow();
await expect(executeTidyTreeLayout(invalidData, mockConfig)).rejects.toThrow(
'No nodes found in layout data'
);
});
it('should handle empty edges (single node tree)', async () => {
const singleNodeData = {
...mockLayoutData,
edges: [],
nodes: [mockLayoutData.nodes[0]], // Only root node
};
const result = await executeTidyTreeLayout(singleNodeData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toHaveLength(1);
expect(result.edges).toHaveLength(0);
});
it('should create bidirectional dual-tree layout with alternating left/right children', async () => {
const result = await executeTidyTreeLayout(mockLayoutData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toHaveLength(5); // root + 4 children
// 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
// 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');
const child4 = result.nodes.find((node) => node.id === 'child4');
expect(child1).toBeDefined();
expect(child2).toBeDefined();
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)
// 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
// 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
});
});
});

View File

@@ -1,56 +1,348 @@
import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout';
import type { MermaidConfig } from '../../../config.type.js';
import { log } from '../../../logger.js';
import type { LayoutData } from '../../types.js';
import type { LayoutResult } from './types.js';
import {
createCytoscapeInstance,
extractPositionedNodes,
extractPositionedEdges,
} from './cytoscape-setup.js';
import type { LayoutData, Node, Edge } from '../../types.js';
import type { LayoutResult, TidyTreeNode, PositionedNode, PositionedEdge } from './types.js';
/**
* Execute the cose-bilkent layout algorithm on generic layout data
* Execute the tidy-tree layout algorithm on generic layout data
*
* This function takes layout data and uses Cytoscape with the cose-bilkent
* algorithm to calculate optimal node positions and edge paths.
* This function takes layout data and uses the non-layered-tidy-tree-layout
* algorithm to calculate optimal node positions for tree structures.
*
* @param data - The layout data containing nodes, edges, and configuration
* @param config - Mermaid configuration object
* @returns Promise resolving to layout result with positioned nodes and edges
*/
export async function executeCoseBilkentLayout(
export function executeTidyTreeLayout(
data: LayoutData,
_config: MermaidConfig
): Promise<LayoutResult> {
log.debug('Starting cose-bilkent layout algorithm');
log.debug('Starting tidy-tree layout algorithm');
return new Promise((resolve, reject) => {
try {
// Validate input data
if (!data.nodes || !Array.isArray(data.nodes)) {
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)) {
throw new Error('No edges found in layout data');
data.edges = []; // Allow empty edges for single-node trees
}
// Create and configure cytoscape instance
const cy = await createCytoscapeInstance(data);
// Convert layout data to dual-tree format (left and right trees)
const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data);
// Extract positioned nodes and edges after layout
const positionedNodes = extractPositionedNodes(cy);
const positionedEdges = extractPositionedEdges(cy);
// Configure tidy-tree layout
const gap = 20; // Horizontal gap between nodes
const bottomPadding = 40; // Vertical gap between levels
const bb = new BoundingBox(gap, bottomPadding);
const layout = new Layout(bb);
log.debug(`Layout completed: ${positionedNodes.length} nodes, ${positionedEdges.length} edges`);
// Execute layout algorithm for both trees
let leftResult = null;
let rightResult = null;
let leftBoundingBox = null;
let rightBoundingBox = null;
return {
if (leftTree) {
const leftLayoutResult = layout.layout(leftTree);
leftResult = leftLayoutResult.result;
leftBoundingBox = leftLayoutResult.boundingBox;
}
if (rightTree) {
const rightLayoutResult = layout.layout(rightTree);
rightResult = rightLayoutResult.result;
rightBoundingBox = rightLayoutResult.boundingBox;
}
// Combine and position the trees
const positionedNodes = combineAndPositionTrees(
rootNode,
leftResult,
rightResult,
leftBoundingBox,
rightBoundingBox
);
const positionedEdges = calculateEdgePositions(data.edges, positionedNodes);
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 cose-bilkent layout algorithm:', error);
throw error;
log.error('Error in tidy-tree layout algorithm:', error);
reject(error);
}
});
}
/**
* Convert LayoutData to dual-tree format (left and right trees)
*
* This function builds two separate tree structures from the nodes and edges,
* alternating children between left and right trees.
*/
function convertToDualTreeFormat(data: LayoutData): {
leftTree: TidyTreeNode | null;
rightTree: TidyTreeNode | null;
rootNode: TidyTreeNode;
} {
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>();
edges.forEach((edge) => {
const parentId = edge.start;
const childId = edge.end;
if (!children.has(parentId)) {
children.set(parentId, []);
}
children.get(parentId)!.push(childId);
parents.set(childId, parentId);
});
// 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');
}
const actualRoot = rootNodeData || nodes[0];
// Create root node
const rootNode: TidyTreeNode = {
id: actualRoot.id,
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 leftChildren: string[] = [];
const rightChildren: string[] = [];
rootChildren.forEach((childId, index) => {
if (index % 2 === 0) {
leftChildren.push(childId);
} else {
rightChildren.push(childId);
}
});
// Build left and right trees
const leftTree = leftChildren.length > 0 ? buildSubTree(leftChildren, children, nodeMap) : null;
const rightTree =
rightChildren.length > 0 ? buildSubTree(rightChildren, children, nodeMap) : null;
return { leftTree, rightTree, rootNode };
}
/**
* Build a subtree from a list of root children
*/
function buildSubTree(
rootChildren: string[],
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
height: 1,
children: rootChildren
.map((childId) => nodeMap.get(childId))
.filter((child): child is Node => child !== undefined)
.map((child) => convertNodeToTidyTree(child, children, nodeMap)),
};
return virtualRoot;
}
/**
* Recursively convert a node and its children to tidy-tree format
*/
function convertNodeToTidyTree(
node: Node,
children: Map<string, string[]>,
nodeMap: Map<string, Node>
): TidyTreeNode {
const childIds = children.get(node.id) || [];
const childNodes = childIds
.map((childId) => nodeMap.get(childId))
.filter((child): child is Node => child !== undefined)
.map((child) => convertNodeToTidyTree(child, children, nodeMap));
return {
id: node.id,
width: node.width || 100,
height: node.height || 50,
children: childNodes.length > 0 ? childNodes : undefined,
// Store original node data for later use
_originalNode: node,
};
}
/**
* Combine and position the left and right trees around the root node
* Creates a bidirectional layout where left tree grows left and right tree grows right
*/
function combineAndPositionTrees(
rootNode: TidyTreeNode,
leftResult: TidyTreeNode | null,
rightResult: TidyTreeNode | null,
leftBoundingBox: any,
rightBoundingBox: any
): PositionedNode[] {
const positionedNodes: PositionedNode[] = [];
// Calculate root position (center of the layout)
const rootX = 0;
const rootY = 0;
// Position the root node
positionedNodes.push({
id: rootNode.id,
x: rootX,
y: rootY,
});
// Calculate spacing between trees
const treeSpacing = 150; // Horizontal spacing from root to tree
// Position left tree (grows to the left)
if (leftResult && leftResult.children) {
positionLeftTreeBidirectional(leftResult.children, positionedNodes, rootX - treeSpacing, rootY);
}
// Position right tree (grows to the right)
if (rightResult && rightResult.children) {
positionRightTreeBidirectional(
rightResult.children,
positionedNodes,
rootX + treeSpacing,
rootY
);
}
return positionedNodes;
}
/**
* Position nodes from the left tree in a bidirectional layout (grows to the left)
* Rotates the tree 90 degrees counterclockwise so it grows horizontally to the left
*/
function positionLeftTreeBidirectional(
nodes: TidyTreeNode[],
positionedNodes: PositionedNode[],
offsetX: number,
offsetY: number
): void {
nodes.forEach((node) => {
// Rotate 90 degrees counterclockwise: (x,y) -> (-y, x)
// Then mirror horizontally for left growth: (-y, x) -> (y, x)
const rotatedX = node.y || 0; // Use y as new x (grows left)
const rotatedY = node.x || 0; // Use x as new y (vertical spread)
positionedNodes.push({
id: node.id,
x: offsetX - rotatedX, // Negative to grow left from root
y: offsetY + rotatedY,
});
if (node.children) {
positionLeftTreeBidirectional(node.children, positionedNodes, offsetX, offsetY);
}
});
}
/**
* Position nodes from the right tree in a bidirectional layout (grows to the right)
* Rotates the tree 90 degrees clockwise so it grows horizontally to the right
*/
function positionRightTreeBidirectional(
nodes: TidyTreeNode[],
positionedNodes: PositionedNode[],
offsetX: number,
offsetY: number
): void {
nodes.forEach((node) => {
// Rotate 90 degrees clockwise: (x,y) -> (y, -x)
const rotatedX = node.y || 0; // Use y as new x (grows right)
const rotatedY = -(node.x || 0); // Use -x as new y (vertical spread)
positionedNodes.push({
id: node.id,
x: offsetX + rotatedX, // Positive to grow right from root
y: offsetY + rotatedY,
});
if (node.children) {
positionRightTreeBidirectional(node.children, positionedNodes, offsetX, offsetY);
}
});
}
/**
* Calculate edge positions based on positioned nodes
*/
function calculateEdgePositions(
edges: Edge[],
positionedNodes: PositionedNode[]
): PositionedEdge[] {
const nodePositions = new Map<string, { x: number; y: number }>();
positionedNodes.forEach((node) => {
nodePositions.set(node.id, { x: node.x, y: node.y });
});
return edges.map((edge) => {
const startPos = nodePositions.get(edge.start) || { x: 0, y: 0 };
const endPos = nodePositions.get(edge.end) || { x: 0, y: 0 };
// Calculate midpoint for edge
const midX = (startPos.x + endPos.x) / 2;
const midY = (startPos.y + endPos.y) / 2;
return {
id: edge.id,
source: edge.start,
target: edge.end,
startX: startPos.x,
startY: startPos.y,
midX,
midY,
endX: endPos.x,
endY: endPos.y,
};
});
}
/**

View File

@@ -1,21 +1,28 @@
import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid';
import { executeCoseBilkentLayout } from './layout.js';
import type { InternalHelpers, LayoutData, RenderOptions, SVG } from 'mermaid';
import { executeTidyTreeLayout } from './layout.js';
type Node = LayoutData['nodes'][number];
interface NodeWithPosition extends Node {
interface NodeWithPosition {
id: string;
x?: number;
y?: number;
domId?: SVGGroup;
width?: number;
height?: number;
domId?: any; // SVG element reference
[key: string]: any; // Allow additional properties from original node
}
/**
* Render function for cose-bilkent layout algorithm
* Render function for bidirectional tidy-tree layout algorithm
*
* This follows the same pattern as ELK and dagre renderers:
* 1. Insert nodes into DOM to get their actual dimensions
* 2. Run the layout algorithm to calculate positions
* 2. Run the bidirectional tidy-tree layout algorithm to calculate positions
* 3. Position the nodes and edges based on layout results
*
* The bidirectional layout creates two trees that grow horizontally in opposite
* directions from a central root node:
* - Left tree: grows horizontally to the left (children: 1st, 3rd, 5th...)
* - Right tree: grows horizontally to the right (children: 2nd, 4th, 6th...)
*/
export const render = async (
data4Layout: LayoutData,
@@ -29,7 +36,7 @@ export const render = async (
log,
positionEdgeLabel,
}: InternalHelpers,
{ algorithm }: RenderOptions
{ algorithm: _algorithm }: RenderOptions
) => {
const nodeDb: Record<string, NodeWithPosition> = {};
const clusterDb: Record<string, any> = {};
@@ -51,7 +58,12 @@ export const render = async (
data4Layout.nodes.map(async (node) => {
if (node.isGroup) {
// Handle subgraphs/clusters
const clusterNode: NodeWithPosition = { ...node };
const clusterNode: NodeWithPosition = {
...node,
id: node.id,
width: node.width,
height: node.height,
};
clusterDb[node.id] = clusterNode;
nodeDb[node.id] = clusterNode;
@@ -59,7 +71,12 @@ export const render = async (
await insertCluster(subGraphsEl, node);
} else {
// Handle regular nodes
const nodeWithPosition: NodeWithPosition = { ...node };
const nodeWithPosition: NodeWithPosition = {
...node,
id: node.id,
width: node.width,
height: node.height,
};
nodeDb[node.id] = nodeWithPosition;
// Insert node to get actual dimensions
@@ -79,8 +96,8 @@ export const render = async (
})
);
// Step 2: Run the cose-bilkent layout algorithm
log.debug('Running cose-bilkent layout algorithm');
// 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 = {
@@ -89,29 +106,32 @@ export const render = async (
const nodeWithDimensions = nodeDb[node.id];
return {
...node,
width: nodeWithDimensions.width,
height: nodeWithDimensions.height,
width: nodeWithDimensions.width || node.width || 100,
height: nodeWithDimensions.height || node.height || 50,
};
}),
};
const layoutResult = await executeCoseBilkentLayout(updatedLayoutData, data4Layout.config);
const layoutResult = await executeTidyTreeLayout(updatedLayoutData, data4Layout.config);
// Step 3: Position the nodes based on layout results
log.debug('Positioning nodes based on layout results');
// 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 && node.domId) {
// Position the node at the calculated coordinates
// The positionedNode.x/y represents the center of the node, so use directly
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;
log.debug(`Positioned node ${node.id} at center (${positionedNode.x}, ${positionedNode.y})`);
log.debug(`Positioned node ${node.id} at (${positionedNode.x}, ${positionedNode.y})`);
}
});
@@ -121,11 +141,11 @@ export const render = async (
await Promise.all(
data4Layout.edges.map(async (edge) => {
// Insert edge label first
const edgeLabel = await insertEdgeLabel(edgeLabels, edge);
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
@@ -179,5 +199,5 @@ export const render = async (
})
);
log.debug('Cose-bilkent rendering completed');
log.debug('Bidirectional tidy-tree rendering completed');
};

View File

@@ -1,3 +1,5 @@
import type { Node } from '../../types.js';
/**
* Positioned node after layout calculation
*/
@@ -33,11 +35,22 @@ export interface LayoutResult {
}
/**
* Cytoscape layout configuration
* Tidy-tree node structure compatible with non-layered-tidy-tree-layout
*/
export interface CytoscapeLayoutConfig {
name: 'cose-bilkent';
quality: 'proof';
styleEnabled: boolean;
animate: boolean;
export interface TidyTreeNode {
id: string | number;
width: number;
height: number;
x?: number;
y?: number;
children?: TidyTreeNode[];
_originalNode?: Node; // Store reference to original node data
}
/**
* Tidy-tree layout configuration
*/
export interface TidyTreeLayoutConfig {
gap: number; // Horizontal gap between nodes
bottomPadding: number; // Vertical gap between levels
}