mirror of
				https://github.com/mermaid-js/mermaid.git
				synced 2025-11-03 20:34:20 +01:00 
			
		
		
		
	Tidy-tree WIP
This commit is contained in:
		@@ -116,6 +116,7 @@
 | 
			
		||||
        Tools
 | 
			
		||||
          Pen and paper
 | 
			
		||||
          Mermaid
 | 
			
		||||
        Third
 | 
			
		||||
    </pre>
 | 
			
		||||
    <pre id="diagram4" class="mermaid">
 | 
			
		||||
      ---
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										131
									
								
								instructions.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								instructions.md
									
									
									
									
									
										Normal 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:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
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`
 | 
			
		||||
```
 | 
			
		||||
@@ -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,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -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;
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -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');
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user