mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-11-05 21:34:14 +01:00
Tidy-tree WIP
This commit is contained in:
@@ -116,6 +116,7 @@
|
|||||||
Tools
|
Tools
|
||||||
Pen and paper
|
Pen and paper
|
||||||
Mermaid
|
Mermaid
|
||||||
|
Third
|
||||||
</pre>
|
</pre>
|
||||||
<pre id="diagram4" class="mermaid">
|
<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
|
* This module provides a layout algorithm implementation using the
|
||||||
* with the cose-bilkent algorithm for positioning nodes and edges.
|
* 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
|
* The algorithm follows the unified rendering pattern and can be used
|
||||||
* by any diagram type that provides compatible LayoutData.
|
* 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.
|
* 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.
|
* 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 layoutData - Layout data containing nodes, edges, and configuration
|
||||||
* @param svg - SVG element to render to
|
* @param svg - SVG element to render to
|
||||||
* @param helpers - Internal helper functions for rendering
|
* @param helpers - Internal helper functions for rendering
|
||||||
* @param options - Rendering options
|
* @param options - Rendering options
|
||||||
*/
|
*/
|
||||||
export const render = renderWithCoseBilkent;
|
export const render = renderWithTidyTree;
|
||||||
|
|||||||
@@ -1,160 +1,190 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock cytoscape and cytoscape-cose-bilkent before importing the modules
|
// Mock non-layered-tidy-tree-layout
|
||||||
vi.mock('cytoscape', () => {
|
vi.mock('non-layered-tidy-tree-layout', () => ({
|
||||||
const mockCy = {
|
BoundingBox: vi.fn().mockImplementation(() => ({})),
|
||||||
add: vi.fn(),
|
Layout: vi.fn().mockImplementation(() => ({
|
||||||
nodes: vi.fn(() => ({
|
layout: vi.fn().mockImplementation((treeData) => {
|
||||||
forEach: vi.fn(),
|
// Return a result based on the input tree structure
|
||||||
map: vi.fn((fn) => [
|
const result = { ...treeData };
|
||||||
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({})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCytoscape = vi.fn(() => mockCy);
|
// Set positions for the virtual root (if it exists)
|
||||||
mockCytoscape.use = vi.fn();
|
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 {
|
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 modules after mocks
|
||||||
import { layout, validateLayoutData } from './index.js';
|
import { executeTidyTreeLayout, validateLayoutData } from './layout.js';
|
||||||
import type { MindmapLayoutData, LayoutResult } from './types.js';
|
import type { LayoutResult } from './types.js';
|
||||||
import type { MindmapNode } from '../../../diagrams/mindmap/mindmapTypes.js';
|
import type { LayoutData } from '../../types.js';
|
||||||
import type { MermaidConfig } from '../../../config.type.js';
|
import type { MermaidConfig } from '../../../config.type.js';
|
||||||
|
|
||||||
describe('Cose-Bilkent Layout Algorithm', () => {
|
describe('Tidy-Tree Layout Algorithm', () => {
|
||||||
let mockConfig: MermaidConfig;
|
let mockConfig: MermaidConfig;
|
||||||
let mockRootNode: MindmapNode;
|
let mockLayoutData: LayoutData;
|
||||||
let mockLayoutData: MindmapLayoutData;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
mindmap: {
|
theme: 'default',
|
||||||
layoutAlgorithm: 'cose-bilkent',
|
|
||||||
padding: 10,
|
|
||||||
maxNodeWidth: 200,
|
|
||||||
useMaxWidth: true,
|
|
||||||
},
|
|
||||||
} as MermaidConfig;
|
} 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 = {
|
mockLayoutData = {
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: 'root',
|
||||||
nodeId: '1',
|
label: 'Root',
|
||||||
level: 0,
|
isGroup: false,
|
||||||
descr: 'Root',
|
shape: 'rect',
|
||||||
type: 0,
|
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 50,
|
height: 50,
|
||||||
padding: 10,
|
padding: 10,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
cssClasses: '',
|
||||||
|
cssStyles: [],
|
||||||
|
look: 'default',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: 'child1',
|
||||||
nodeId: '2',
|
label: 'Child 1',
|
||||||
level: 1,
|
isGroup: false,
|
||||||
descr: 'Child 1',
|
shape: 'rect',
|
||||||
type: 0,
|
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 40,
|
height: 40,
|
||||||
padding: 10,
|
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: [
|
edges: [
|
||||||
{
|
{
|
||||||
id: '1_2',
|
id: 'root_child1',
|
||||||
source: '1',
|
start: 'root',
|
||||||
target: '2',
|
end: 'child1',
|
||||||
depth: 0,
|
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,
|
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');
|
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', () => {
|
it('should throw error for missing config', () => {
|
||||||
const invalidData = { ...mockLayoutData, config: null as any };
|
const invalidData = { ...mockLayoutData, config: null as any };
|
||||||
expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required');
|
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 () => {
|
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).toBeDefined();
|
||||||
expect(result.nodes).toBeDefined();
|
expect(result.nodes).toBeDefined();
|
||||||
@@ -200,7 +225,7 @@ describe('Cose-Bilkent Layout Algorithm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return positioned nodes with coordinates', async () => {
|
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);
|
expect(result.nodes.length).toBeGreaterThan(0);
|
||||||
result.nodes.forEach((node) => {
|
result.nodes.forEach((node) => {
|
||||||
@@ -212,7 +237,7 @@ describe('Cose-Bilkent Layout Algorithm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return positioned edges with coordinates', async () => {
|
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);
|
expect(result.edges.length).toBeGreaterThan(0);
|
||||||
result.edges.forEach((edge) => {
|
result.edges.forEach((edge) => {
|
||||||
@@ -225,26 +250,77 @@ describe('Cose-Bilkent Layout Algorithm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty mindmap data gracefully', async () => {
|
it('should handle empty layout data gracefully', async () => {
|
||||||
const emptyData: MindmapLayoutData = {
|
const emptyData: LayoutData = {
|
||||||
|
...mockLayoutData,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
config: mockConfig,
|
|
||||||
rootNode: mockRootNode,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result: LayoutResult = await layout(emptyData, mockConfig);
|
await expect(executeTidyTreeLayout(emptyData, mockConfig)).rejects.toThrow(
|
||||||
expect(result).toBeDefined();
|
'No nodes found in layout data'
|
||||||
expect(result.nodes).toBeDefined();
|
);
|
||||||
expect(result.edges).toBeDefined();
|
|
||||||
expect(Array.isArray(result.nodes)).toBe(true);
|
|
||||||
expect(Array.isArray(result.edges)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for invalid data', async () => {
|
it('should throw error for missing nodes', async () => {
|
||||||
const invalidData = { ...mockLayoutData, rootNode: null as any };
|
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 type { MermaidConfig } from '../../../config.type.js';
|
||||||
import { log } from '../../../logger.js';
|
import { log } from '../../../logger.js';
|
||||||
import type { LayoutData } from '../../types.js';
|
import type { LayoutData, Node, Edge } from '../../types.js';
|
||||||
import type { LayoutResult } from './types.js';
|
import type { LayoutResult, TidyTreeNode, PositionedNode, PositionedEdge } from './types.js';
|
||||||
import {
|
|
||||||
createCytoscapeInstance,
|
|
||||||
extractPositionedNodes,
|
|
||||||
extractPositionedEdges,
|
|
||||||
} from './cytoscape-setup.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
|
* This function takes layout data and uses the non-layered-tidy-tree-layout
|
||||||
* algorithm to calculate optimal node positions and edge paths.
|
* algorithm to calculate optimal node positions for tree structures.
|
||||||
*
|
*
|
||||||
* @param data - The layout data containing nodes, edges, and configuration
|
* @param data - The layout data containing nodes, edges, and configuration
|
||||||
* @param config - Mermaid configuration object
|
* @param config - Mermaid configuration object
|
||||||
* @returns Promise resolving to layout result with positioned nodes and edges
|
* @returns Promise resolving to layout result with positioned nodes and edges
|
||||||
*/
|
*/
|
||||||
export async function executeCoseBilkentLayout(
|
export function executeTidyTreeLayout(
|
||||||
data: LayoutData,
|
data: LayoutData,
|
||||||
_config: MermaidConfig
|
_config: MermaidConfig
|
||||||
): Promise<LayoutResult> {
|
): Promise<LayoutResult> {
|
||||||
log.debug('Starting cose-bilkent layout algorithm');
|
log.debug('Starting tidy-tree layout algorithm');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
// Validate input data
|
// 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');
|
throw new Error('No nodes found in layout data');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.edges || !Array.isArray(data.edges)) {
|
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
|
// Convert layout data to dual-tree format (left and right trees)
|
||||||
const cy = await createCytoscapeInstance(data);
|
const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data);
|
||||||
|
|
||||||
// Extract positioned nodes and edges after layout
|
// Configure tidy-tree layout
|
||||||
const positionedNodes = extractPositionedNodes(cy);
|
const gap = 20; // Horizontal gap between nodes
|
||||||
const positionedEdges = extractPositionedEdges(cy);
|
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,
|
nodes: positionedNodes,
|
||||||
edges: positionedEdges,
|
edges: positionedEdges,
|
||||||
};
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Error in cose-bilkent layout algorithm:', error);
|
log.error('Error in tidy-tree layout algorithm:', error);
|
||||||
throw 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 type { InternalHelpers, LayoutData, RenderOptions, SVG } from 'mermaid';
|
||||||
import { executeCoseBilkentLayout } from './layout.js';
|
import { executeTidyTreeLayout } from './layout.js';
|
||||||
|
|
||||||
type Node = LayoutData['nodes'][number];
|
interface NodeWithPosition {
|
||||||
|
id: string;
|
||||||
interface NodeWithPosition extends Node {
|
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: 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:
|
* This follows the same pattern as ELK and dagre renderers:
|
||||||
* 1. Insert nodes into DOM to get their actual dimensions
|
* 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
|
* 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 (
|
export const render = async (
|
||||||
data4Layout: LayoutData,
|
data4Layout: LayoutData,
|
||||||
@@ -29,7 +36,7 @@ export const render = async (
|
|||||||
log,
|
log,
|
||||||
positionEdgeLabel,
|
positionEdgeLabel,
|
||||||
}: InternalHelpers,
|
}: InternalHelpers,
|
||||||
{ algorithm }: RenderOptions
|
{ algorithm: _algorithm }: RenderOptions
|
||||||
) => {
|
) => {
|
||||||
const nodeDb: Record<string, NodeWithPosition> = {};
|
const nodeDb: Record<string, NodeWithPosition> = {};
|
||||||
const clusterDb: Record<string, any> = {};
|
const clusterDb: Record<string, any> = {};
|
||||||
@@ -51,7 +58,12 @@ export const render = async (
|
|||||||
data4Layout.nodes.map(async (node) => {
|
data4Layout.nodes.map(async (node) => {
|
||||||
if (node.isGroup) {
|
if (node.isGroup) {
|
||||||
// Handle subgraphs/clusters
|
// Handle subgraphs/clusters
|
||||||
const clusterNode: NodeWithPosition = { ...node };
|
const clusterNode: NodeWithPosition = {
|
||||||
|
...node,
|
||||||
|
id: node.id,
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
};
|
||||||
clusterDb[node.id] = clusterNode;
|
clusterDb[node.id] = clusterNode;
|
||||||
nodeDb[node.id] = clusterNode;
|
nodeDb[node.id] = clusterNode;
|
||||||
|
|
||||||
@@ -59,7 +71,12 @@ export const render = async (
|
|||||||
await insertCluster(subGraphsEl, node);
|
await insertCluster(subGraphsEl, node);
|
||||||
} else {
|
} else {
|
||||||
// Handle regular nodes
|
// Handle regular nodes
|
||||||
const nodeWithPosition: NodeWithPosition = { ...node };
|
const nodeWithPosition: NodeWithPosition = {
|
||||||
|
...node,
|
||||||
|
id: node.id,
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
};
|
||||||
nodeDb[node.id] = nodeWithPosition;
|
nodeDb[node.id] = nodeWithPosition;
|
||||||
|
|
||||||
// Insert node to get actual dimensions
|
// Insert node to get actual dimensions
|
||||||
@@ -79,8 +96,8 @@ export const render = async (
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 2: Run the cose-bilkent layout algorithm
|
// Step 2: Run the bidirectional tidy-tree layout algorithm
|
||||||
log.debug('Running cose-bilkent layout algorithm');
|
log.debug('Running bidirectional tidy-tree layout algorithm');
|
||||||
|
|
||||||
// Update the layout data with actual dimensions
|
// Update the layout data with actual dimensions
|
||||||
const updatedLayoutData = {
|
const updatedLayoutData = {
|
||||||
@@ -89,29 +106,32 @@ export const render = async (
|
|||||||
const nodeWithDimensions = nodeDb[node.id];
|
const nodeWithDimensions = nodeDb[node.id];
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
width: nodeWithDimensions.width,
|
width: nodeWithDimensions.width || node.width || 100,
|
||||||
height: nodeWithDimensions.height,
|
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
|
// Step 3: Position the nodes based on bidirectional layout results
|
||||||
log.debug('Positioning nodes based on layout results');
|
log.debug('Positioning nodes based on bidirectional layout results');
|
||||||
|
|
||||||
layoutResult.nodes.forEach((positionedNode) => {
|
layoutResult.nodes.forEach((positionedNode) => {
|
||||||
const node = nodeDb[positionedNode.id];
|
const node = nodeDb[positionedNode.id];
|
||||||
if (node && node.domId) {
|
if (node?.domId) {
|
||||||
// Position the node at the calculated coordinates
|
// Position the node at the calculated coordinates from bidirectional layout
|
||||||
// The positionedNode.x/y represents the center of the node, so use directly
|
// 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})`);
|
node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`);
|
||||||
|
|
||||||
// Store the final position
|
// Store the final position
|
||||||
node.x = positionedNode.x;
|
node.x = positionedNode.x;
|
||||||
node.y = positionedNode.y;
|
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(
|
await Promise.all(
|
||||||
data4Layout.edges.map(async (edge) => {
|
data4Layout.edges.map(async (edge) => {
|
||||||
// Insert edge label first
|
// Insert edge label first
|
||||||
const edgeLabel = await insertEdgeLabel(edgeLabels, edge);
|
await insertEdgeLabel(edgeLabels, edge);
|
||||||
|
|
||||||
// Get start and end nodes
|
// Get start and end nodes
|
||||||
const startNode = nodeDb[edge.start];
|
const startNode = nodeDb[edge.start || ''];
|
||||||
const endNode = nodeDb[edge.end];
|
const endNode = nodeDb[edge.end || ''];
|
||||||
|
|
||||||
if (startNode && endNode) {
|
if (startNode && endNode) {
|
||||||
// Find the positioned edge data
|
// 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
|
* 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 {
|
export interface TidyTreeNode {
|
||||||
name: 'cose-bilkent';
|
id: string | number;
|
||||||
quality: 'proof';
|
width: number;
|
||||||
styleEnabled: boolean;
|
height: number;
|
||||||
animate: boolean;
|
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