#6646 Tidy-tree layout in place for Mindmaps

This commit is contained in:
Knut Sveidqvist
2025-06-12 13:33:05 +02:00
parent ba52eef257
commit f0445b74d1
5 changed files with 278 additions and 98 deletions

View File

@@ -105,7 +105,7 @@
</head>
<body>
<pre id="diagram4" class="mermaid">
<pre id="diagram4" class="mermaid2">
---
config:
layout: tidy-tree
@@ -135,31 +135,25 @@
layout: tidy-tree
---
mindmap
root((mindmap))
root((mindmap is a long thing))
A
B
a
b
c
d
a1
a2
C
e
f
g
h
i
D
q1
q2
I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on
q3
q4
</pre>
<pre id="diagram4" class="mermaid2">
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
A
B
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
@@ -167,22 +161,37 @@
mindmap
root((mindmap))
A
Origins
Tools
e
Third
q
w
e
r
t
y
Forth
a
apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
b
c
d
B
apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
D
apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
a
apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
b
apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
@@ -210,7 +219,7 @@
layout: dagre
---
mindmap
root((mindmap))
root((mindmap is a long thing))
Origins
Long history
::icon(fa fa-book)

View File

@@ -29,7 +29,7 @@ export const render = async (
log,
positionEdgeLabel,
}: InternalHelpers,
{ algorithm }: RenderOptions
{ algorithm: _algorithm }: RenderOptions
) => {
const nodeDb: Record<string, NodeWithPosition> = {};
const clusterDb: Record<string, any> = {};
@@ -121,7 +121,7 @@ export const render = async (
await Promise.all(
data4Layout.edges.map(async (edge) => {
// Insert edge label first
const edgeLabel = await insertEdgeLabel(edgeLabels, edge);
const _edgeLabel = await insertEdgeLabel(edgeLabels, edge);
// Get start and end nodes
const startNode = nodeDb[edge.start];
@@ -132,7 +132,7 @@ export const render = async (
const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id);
if (positionedEdge) {
console.debug('APA01 positionedEdge', positionedEdge);
log.debug('APA01 positionedEdge', positionedEdge);
// Create edge path with positioned coordinates
const edgeWithPath = {
...edge,

View File

@@ -9,7 +9,7 @@ vi.mock('non-layered-tidy-tree-layout', () => ({
const result = { ...treeData };
// Set positions for the virtual root (if it exists)
if (result.id && result.id.toString().startsWith('virtual-root')) {
if (result.id?.toString().startsWith('virtual-root')) {
result.x = 0;
result.y = 0;
} else {
@@ -322,5 +322,109 @@ describe('Tidy-Tree Layout Algorithm', () => {
expect(child2!.x).toBeGreaterThan(100); // Should be significantly right of center
expect(child4!.x).toBeGreaterThan(100); // Should be significantly right of center
});
it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => {
// Create a test case with nodes that have different heights to test transposition
const testData = {
...mockLayoutData,
nodes: [
{
id: 'root',
label: 'Root',
isGroup: false,
shape: 'rect' as const,
width: 100,
height: 50,
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: 'tall-child',
label: 'Tall Child',
isGroup: false,
shape: 'rect' as const,
width: 80,
height: 120, // Tall node
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: 'short-child',
label: 'Short Child',
isGroup: false,
shape: 'rect' as const,
width: 80,
height: 30, // Short node
padding: 10,
x: 0,
y: 0,
cssClasses: '',
cssStyles: [],
look: 'default',
},
],
edges: [
{
id: 'root_tall',
start: 'root',
end: 'tall-child',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
{
id: 'root_short',
start: 'root',
end: 'short-child',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
],
};
const result = await executeTidyTreeLayout(testData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toHaveLength(3); // root + 2 children
// Find all nodes
const rootNode = result.nodes.find((node) => node.id === 'root');
const tallChild = result.nodes.find((node) => node.id === 'tall-child');
const shortChild = result.nodes.find((node) => node.id === 'short-child');
expect(rootNode).toBeDefined();
expect(tallChild).toBeDefined();
expect(shortChild).toBeDefined();
// Verify that nodes are positioned correctly with proper transposition
// The tall child and short child should be on opposite sides
expect(tallChild!.x).not.toBe(shortChild!.x); // Should be on different sides
// Verify that the original dimensions are preserved (not transposed in final output)
expect(tallChild!.width).toBe(80); // Original width preserved
expect(tallChild!.height).toBe(120); // Original height preserved
expect(shortChild!.width).toBe(80); // Original width preserved
expect(shortChild!.height).toBe(30); // Original height preserved
// Verify that nodes don't overlap vertically (the transposition fix)
// Both children should have reasonable Y positions that don't cause overlap
const yDifference = Math.abs(tallChild!.y - shortChild!.y);
expect(yDifference).toBeGreaterThanOrEqual(0); // Should have proper vertical spacing
});
});
});

View File

@@ -19,7 +19,7 @@ export function executeTidyTreeLayout(
data: LayoutData,
_config: MermaidConfig
): Promise<LayoutResult> {
log.debug('Starting tidy-tree layout algorithm');
log.debug('APA01 Starting tidy-tree layout algorithm');
return new Promise((resolve, reject) => {
try {
@@ -33,13 +33,15 @@ export function executeTidyTreeLayout(
}
// Find the maximum number of children any one node have expect for the root node
const maxChildren = Math.max(...data.nodes.map((node) => node.children?.length ?? 0));
const _maxChildren = Math.max(...data.nodes.map((node) => node.children?.length ?? 0));
// Convert layout data to dual-tree format (left and right trees)
const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data);
// Configure tidy-tree layout
const gap = 20; // Vertical gap between nodes
// Configure tidy-tree layout with dynamic gap based on node heights
// Since we transpose coordinates, the gap becomes horizontal spacing in final layout
// We need to ensure enough space for the tallest nodes
const gap = 20; // Math.max(20, maxNodeHeight + 20); // Ensure minimum gap plus node height
const bottomPadding = 40; // Horizontal gap between levels
intersectionShift = 30;
@@ -58,8 +60,10 @@ export function executeTidyTreeLayout(
let rightBoundingBox = null;
if (leftTree) {
// log.debug('APA01 Left tree before layout', leftTree.children[0]?.children[0]);
const leftLayoutResult = layout.layout(leftTree);
leftResult = leftLayoutResult.result;
log.debug('APA01 Left tree before layout', JSON.stringify(leftResult, null, 2));
leftBoundingBox = leftLayoutResult.boundingBox;
}
@@ -128,11 +132,13 @@ function convertToDualTreeFormat(data: LayoutData): {
const parentId = edge.start;
const childId = edge.end;
if (parentId && childId) {
if (!children.has(parentId)) {
children.set(parentId, []);
}
children.get(parentId)!.push(childId);
parents.set(childId, parentId);
}
});
// Find root node (node with no parent)
@@ -241,7 +247,7 @@ function combineAndPositionTrees(
): PositionedNode[] {
const positionedNodes: PositionedNode[] = [];
console.log('combineAndPositionTrees', {
log.debug('combineAndPositionTrees', {
leftResult,
rightResult,
});
@@ -251,7 +257,7 @@ function combineAndPositionTrees(
const rootY = 0;
// Calculate spacing between trees
const treeSpacing = 150; // Horizontal spacing from root to tree
const treeSpacing = rootNode.width / 2 + 30; // Horizontal spacing from root to tree
// First, collect node positions for each tree separately
const leftTreeNodes: PositionedNode[] = [];
@@ -287,8 +293,13 @@ function combineAndPositionTrees(
const firstLevelLeftNodes = leftTreeNodes.filter((node) => node.x === firstLevelLeftX);
if (firstLevelLeftNodes.length > 0) {
const leftMinY = Math.min(...firstLevelLeftNodes.map((node) => node.y));
const leftMaxY = Math.max(...firstLevelLeftNodes.map((node) => node.y));
// Calculate bounding box considering node heights
const leftMinY = Math.min(
...firstLevelLeftNodes.map((node) => node.y - (node.height ?? 50) / 2)
);
const leftMaxY = Math.max(
...firstLevelLeftNodes.map((node) => node.y + (node.height ?? 50) / 2)
);
leftTreeCenterY = (leftMinY + leftMaxY) / 2;
}
}
@@ -303,8 +314,13 @@ function combineAndPositionTrees(
const firstLevelRightNodes = rightTreeNodes.filter((node) => node.x === firstLevelRightX);
if (firstLevelRightNodes.length > 0) {
const rightMinY = Math.min(...firstLevelRightNodes.map((node) => node.y));
const rightMaxY = Math.max(...firstLevelRightNodes.map((node) => node.y));
// Calculate bounding box considering node heights
const rightMinY = Math.min(
...firstLevelRightNodes.map((node) => node.y - (node.height ?? 50) / 2)
);
const rightMaxY = Math.max(
...firstLevelRightNodes.map((node) => node.y + (node.height ?? 50) / 2)
);
rightTreeCenterY = (rightMinY + rightMaxY) / 2;
}
}
@@ -317,7 +333,7 @@ function combineAndPositionTrees(
positionedNodes.push({
id: String(rootNode.id),
x: rootX,
y: rootY, // Root stays at center
y: rootY + 20, // Root stays at center
section: 'root',
width: rootNode._originalNode?.width || rootNode.width,
height: rootNode._originalNode?.height || rootNode.height,
@@ -325,30 +341,31 @@ function combineAndPositionTrees(
});
// Add left tree nodes with their specific offset
leftTreeNodes.forEach((node) => {
positionedNodes.push({
const leftTreeNodesWithOffset = leftTreeNodes.map((node) => ({
id: node.id,
x: node.x,
y: node.y + leftTreeOffset,
section: 'left',
x: node.x - node.width / 2,
y: node.y + leftTreeOffset + node.height / 2,
section: 'left' as const,
width: node.width,
height: node.height,
originalNode: node.originalNode,
});
});
}));
// Add right tree nodes with their specific offset
rightTreeNodes.forEach((node) => {
positionedNodes.push({
const rightTreeNodesWithOffset = rightTreeNodes.map((node) => ({
id: node.id,
x: node.x,
y: node.y + rightTreeOffset,
section: 'right',
x: node.x + node.width / 2,
y: node.y + rightTreeOffset + node.height / 2,
section: 'right' as const,
width: node.width,
height: node.height,
originalNode: node.originalNode,
});
});
}));
// Add all nodes to the final result
// The tidy-tree algorithm should handle proper spacing, so no additional collision detection needed
positionedNodes.push(...leftTreeNodesWithOffset);
positionedNodes.push(...rightTreeNodesWithOffset);
return positionedNodes;
}
@@ -364,18 +381,25 @@ function positionLeftTreeBidirectional(
offsetY: number
): void {
nodes.forEach((node) => {
// For left tree: transpose the tidy-tree coordinates
// Tidy-tree Y becomes our X distance from root (grows left)
// Tidy-tree X becomes our Y position (tree levels) - this gives proper spacing
const distanceFromRoot = node.y ?? 0; // How far left from root
const treeLevel = node.x ?? 0; // Use X coordinate for tree level (proper vertical spacing)
// For left tree: transpose the tidy-tree coordinates correctly
// Tidy-tree X (tree levels going down) becomes our Y position (vertical spacing)
// Tidy-tree Y (sibling spacing) becomes our X distance from root (grows left)
const distanceFromRoot = node.y ?? 0; // Horizontal distance from root (grows left)
const verticalPosition = node.x ?? 0; // Vertical position (tree levels)
// Get the original node dimensions directly from the stored original node
const originalWidth = node._originalNode?.width ?? 100;
const originalHeight = node._originalNode?.height ?? 50;
// Use the vertical position as calculated by the tidy-tree algorithm
const adjustedY = offsetY + verticalPosition;
positionedNodes.push({
id: String(node.id),
x: offsetX - distanceFromRoot, // Negative to grow left from root
y: offsetY + treeLevel, // Use tidy-tree's Y as Y (tree levels)
width: node._originalNode?.width || node.width,
height: node._originalNode?.height || node.height,
y: adjustedY, // Vertical position based on tree level
width: originalWidth,
height: originalHeight,
originalNode: node._originalNode,
});
@@ -396,18 +420,25 @@ function positionRightTreeBidirectional(
offsetY: number
): void {
nodes.forEach((node) => {
// For right tree: transpose the tidy-tree coordinates
// Tidy-tree Y becomes our X distance from root (grows right)
// Tidy-tree X becomes our Y position (tree levels) - this gives proper spacing
const distanceFromRoot = node.y ?? 0; // How far right from root
const treeLevel = node.x ?? 0; // Use X coordinate for tree level (proper vertical spacing)
// For right tree: transpose the tidy-tree coordinates correctly
// Tidy-tree X (tree levels going down) becomes our Y position (vertical spacing)
// Tidy-tree Y (sibling spacing) becomes our X distance from root (grows right)
const distanceFromRoot = node.y ?? 0; // Horizontal distance from root (grows right)
const verticalPosition = node.x ?? 0; // Vertical position (tree levels)
// Get the original node dimensions directly from the stored original node
const originalWidth = node._originalNode?.width ?? 100;
const originalHeight = node._originalNode?.height ?? 50;
// Use the vertical position as calculated by the tidy-tree algorithm
const adjustedY = offsetY + verticalPosition;
positionedNodes.push({
id: String(node.id),
x: offsetX + distanceFromRoot, // Positive to grow right from root
y: offsetY + treeLevel, // Use tidy-tree's Y as Y (tree levels)
width: node._originalNode?.width || node.width,
height: node._originalNode?.height || node.height,
y: adjustedY, // Vertical position based on tree level
width: originalWidth,
height: originalHeight,
originalNode: node._originalNode,
});
@@ -434,15 +465,51 @@ function calculateEdgePositions(
return edges.map((edge) => {
const sourceNode = nodeInfo.get(edge.start ?? '');
const targetNode = nodeInfo.get(edge.end ?? '');
console.debug('APA01 calculateEdgePositions', edge, sourceNode, 'targetNode', targetNode);
log.debug('APA01 calculateEdgePositions', edge, sourceNode, 'targetNode', targetNode);
if (!sourceNode) {
console.error('APA01 Source node not found for edge', edge);
return;
log.error('APA01 Source node not found for edge', edge);
// Return a default edge instead of undefined
return {
id: edge.id,
source: edge.start ?? '',
target: edge.end ?? '',
startX: 0,
startY: 0,
midX: 0,
midY: 0,
endX: 0,
endY: 0,
points: [{ x: 0, y: 0 }],
sourceSection: undefined,
targetSection: undefined,
sourceWidth: undefined,
sourceHeight: undefined,
targetWidth: undefined,
targetHeight: undefined,
};
}
if (targetNode === undefined) {
console.error('APA01 Target node not found for edge', edge);
return;
log.error('APA01 Target node not found for edge', edge);
// Return a default edge instead of undefined
return {
id: edge.id,
source: edge.start ?? '',
target: edge.end ?? '',
startX: 0,
startY: 0,
midX: 0,
midY: 0,
endX: 0,
endY: 0,
points: [{ x: 0, y: 0 }],
sourceSection: undefined,
targetSection: undefined,
sourceWidth: undefined,
sourceHeight: undefined,
targetWidth: undefined,
targetHeight: undefined,
};
}
// Fallback positions if nodes not found

View File

@@ -152,7 +152,7 @@ export const render = async (
const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id);
if (positionedEdge) {
console.debug('APA01 positionedEdge', positionedEdge);
log.debug('APA01 positionedEdge', positionedEdge);
// Create edge path with positioned coordinates
const edgeWithPath = {
...edge,