Tidy-tree getting closer handling of multi point edges

This commit is contained in:
Knut Sveidqvist
2025-06-12 11:21:35 +02:00
parent c13ce2a5c0
commit ba52eef257
5 changed files with 213 additions and 28 deletions

View File

@@ -112,15 +112,87 @@
--- ---
mindmap mindmap
root((mindmap)) root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
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">
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
A
Origins Origins
Tools Tools
e e
Third Third
q
w
e
r
t
y
Forth Forth
a a
b b
</pre> </pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: tidy-tree
---
flowchart TB
A --> n0["1"]
A --> n1["2"]
A --> n2["3"]
A --> n3["4"] --> Q & R & S & T
</pre>
<pre id="diagram4" class="mermaid2"> <pre id="diagram4" class="mermaid2">
--- ---
config: config:
@@ -130,7 +202,7 @@
A --> n0["1"] A --> n0["1"]
A --> n1["2"] A --> n1["2"]
A --> n2["3"] A --> n2["3"]
A --> n3["4"] A --> n3["4"] --> Q & R & S & T
</pre> </pre>
<pre id="diagram4" class="mermaid2"> <pre id="diagram4" class="mermaid2">
--- ---

View File

@@ -102,7 +102,7 @@ export const render = async (
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
// The positionedNode.x/y represents the center of the node, so use directly // The positionedNode.x/y represents the center of the node, so use directly
node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`); node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`);
@@ -132,13 +132,11 @@ export const render = async (
const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id);
if (positionedEdge) { if (positionedEdge) {
console.debug('APA01 positionedEdge', positionedEdge);
// Create edge path with positioned coordinates // Create edge path with positioned coordinates
const edgeWithPath = { const edgeWithPath = {
...edge, ...edge,
points: [ points: positionedEdge.points,
{ x: positionedEdge.startX, y: positionedEdge.startY },
{ x: positionedEdge.endX, y: positionedEdge.endY },
],
}; };
// Insert the edge path // Insert the edge path

View File

@@ -4,6 +4,7 @@ import { log } from '../../../logger.js';
import type { LayoutData, Node, Edge } from '../../types.js'; import type { LayoutData, Node, Edge } from '../../types.js';
import type { LayoutResult, TidyTreeNode, PositionedNode, PositionedEdge } from './types.js'; import type { LayoutResult, TidyTreeNode, PositionedNode, PositionedEdge } from './types.js';
let intersectionShift = 50;
/** /**
* Execute the tidy-tree layout algorithm on generic layout data * Execute the tidy-tree layout algorithm on generic layout data
* *
@@ -31,12 +32,22 @@ export function executeTidyTreeLayout(
data.edges = []; // Allow empty edges for single-node trees data.edges = []; // Allow empty edges for single-node trees
} }
// 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));
// Convert layout data to dual-tree format (left and right trees) // Convert layout data to dual-tree format (left and right trees)
const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data); const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data);
// Configure tidy-tree layout // Configure tidy-tree layout
const gap = 20; // Horizontal gap between nodes const gap = 20; // Vertical gap between nodes
const bottomPadding = 40; // Vertical gap between levels const bottomPadding = 40; // Horizontal gap between levels
intersectionShift = 30;
// if (maxChildren > 5) {
// bottomPadding = 50;
// intersectionShift = 50;
// }
const bb = new BoundingBox(gap, bottomPadding); const bb = new BoundingBox(gap, bottomPadding);
const layout = new Layout(bb); const layout = new Layout(bb);
@@ -47,14 +58,12 @@ export function executeTidyTreeLayout(
let rightBoundingBox = null; let rightBoundingBox = null;
if (leftTree) { if (leftTree) {
console.log('combineAndPositionTrees leftTree', JSON.stringify(leftTree));
const leftLayoutResult = layout.layout(leftTree); const leftLayoutResult = layout.layout(leftTree);
leftResult = leftLayoutResult.result; leftResult = leftLayoutResult.result;
leftBoundingBox = leftLayoutResult.boundingBox; leftBoundingBox = leftLayoutResult.boundingBox;
} }
if (rightTree) { if (rightTree) {
console.log('combineAndPositionTrees leftTree', JSON.stringify(rightTree));
const rightLayoutResult = layout.layout(rightTree); const rightLayoutResult = layout.layout(rightTree);
rightResult = rightLayoutResult.result; rightResult = rightLayoutResult.result;
rightBoundingBox = rightLayoutResult.boundingBox; rightBoundingBox = rightLayoutResult.boundingBox;
@@ -218,7 +227,6 @@ function convertNodeToTidyTreeTransposed(
_originalNode: node, _originalNode: node,
}; };
} }
/** /**
* Combine and position the left and right trees around the root 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 * Creates a bidirectional layout where left tree grows left and right tree grows right
@@ -265,20 +273,41 @@ function combineAndPositionTrees(
} }
// Calculate center points for each tree separately // Calculate center points for each tree separately
// Only use first-level children (direct children of root) for centering
let leftTreeCenterY = 0; let leftTreeCenterY = 0;
let rightTreeCenterY = 0; let rightTreeCenterY = 0;
if (leftTreeNodes.length > 0) { if (leftTreeNodes.length > 0) {
const leftMinY = Math.min(...leftTreeNodes.map((node) => node.y)); // Filter to only first-level children (those closest to root on X axis)
const leftMaxY = Math.max(...leftTreeNodes.map((node) => node.y)); // Find the X coordinate closest to root for left tree nodes
const leftTreeXPositions = [...new Set(leftTreeNodes.map((node) => node.x))].sort(
(a, b) => b - a
); // Sort descending (closest to root first)
const firstLevelLeftX = leftTreeXPositions[0]; // X position closest to root
const 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));
leftTreeCenterY = (leftMinY + leftMaxY) / 2; leftTreeCenterY = (leftMinY + leftMaxY) / 2;
} }
}
if (rightTreeNodes.length > 0) { if (rightTreeNodes.length > 0) {
const rightMinY = Math.min(...rightTreeNodes.map((node) => node.y)); // Filter to only first-level children (those closest to root on X axis)
const rightMaxY = Math.max(...rightTreeNodes.map((node) => node.y)); // Find the X coordinate closest to root for right tree nodes
const rightTreeXPositions = [...new Set(rightTreeNodes.map((node) => node.x))].sort(
(a, b) => a - b
); // Sort ascending (closest to root first)
const firstLevelRightX = rightTreeXPositions[0]; // X position closest to root
const 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));
rightTreeCenterY = (rightMinY + rightMaxY) / 2; rightTreeCenterY = (rightMinY + rightMaxY) / 2;
} }
}
// Calculate different offsets for each tree to center them around the root // Calculate different offsets for each tree to center them around the root
const leftTreeOffset = -leftTreeCenterY; const leftTreeOffset = -leftTreeCenterY;
@@ -289,6 +318,10 @@ function combineAndPositionTrees(
id: String(rootNode.id), id: String(rootNode.id),
x: rootX, x: rootX,
y: rootY, // Root stays at center y: rootY, // Root stays at center
section: 'root',
width: rootNode._originalNode?.width || rootNode.width,
height: rootNode._originalNode?.height || rootNode.height,
originalNode: rootNode._originalNode,
}); });
// Add left tree nodes with their specific offset // Add left tree nodes with their specific offset
@@ -297,6 +330,10 @@ function combineAndPositionTrees(
id: node.id, id: node.id,
x: node.x, x: node.x,
y: node.y + leftTreeOffset, y: node.y + leftTreeOffset,
section: 'left',
width: node.width,
height: node.height,
originalNode: node.originalNode,
}); });
}); });
@@ -306,6 +343,10 @@ function combineAndPositionTrees(
id: node.id, id: node.id,
x: node.x, x: node.x,
y: node.y + rightTreeOffset, y: node.y + rightTreeOffset,
section: 'right',
width: node.width,
height: node.height,
originalNode: node.originalNode,
}); });
}); });
@@ -333,6 +374,9 @@ function positionLeftTreeBidirectional(
id: String(node.id), id: String(node.id),
x: offsetX - distanceFromRoot, // Negative to grow left from root x: offsetX - distanceFromRoot, // Negative to grow left from root
y: offsetY + treeLevel, // Use tidy-tree's Y as Y (tree levels) y: offsetY + treeLevel, // Use tidy-tree's Y as Y (tree levels)
width: node._originalNode?.width || node.width,
height: node._originalNode?.height || node.height,
originalNode: node._originalNode,
}); });
if (node.children) { if (node.children) {
@@ -362,6 +406,9 @@ function positionRightTreeBidirectional(
id: String(node.id), id: String(node.id),
x: offsetX + distanceFromRoot, // Positive to grow right from root x: offsetX + distanceFromRoot, // Positive to grow right from root
y: offsetY + treeLevel, // Use tidy-tree's Y as Y (tree levels) y: offsetY + treeLevel, // Use tidy-tree's Y as Y (tree levels)
width: node._originalNode?.width || node.width,
height: node._originalNode?.height || node.height,
originalNode: node._originalNode,
}); });
if (node.children) { if (node.children) {
@@ -372,34 +419,85 @@ function positionRightTreeBidirectional(
/** /**
* Calculate edge positions based on positioned nodes * Calculate edge positions based on positioned nodes
* Now includes tree membership and node dimensions for precise edge calculations
*/ */
function calculateEdgePositions( function calculateEdgePositions(
edges: Edge[], edges: Edge[],
positionedNodes: PositionedNode[] positionedNodes: PositionedNode[]
): PositionedEdge[] { ): PositionedEdge[] {
const nodePositions = new Map<string, { x: number; y: number }>(); // Create a comprehensive map of node information
const nodeInfo = new Map<string, PositionedNode>();
positionedNodes.forEach((node) => { positionedNodes.forEach((node) => {
nodePositions.set(node.id, { x: node.x, y: node.y }); nodeInfo.set(node.id, node);
}); });
return edges.map((edge) => { return edges.map((edge) => {
const startPos = nodePositions.get(edge.start || '') || { x: 0, y: 0 }; const sourceNode = nodeInfo.get(edge.start ?? '');
const endPos = nodePositions.get(edge.end || '') || { x: 0, y: 0 }; const targetNode = nodeInfo.get(edge.end ?? '');
console.debug('APA01 calculateEdgePositions', edge, sourceNode, 'targetNode', targetNode);
if (!sourceNode) {
console.error('APA01 Source node not found for edge', edge);
return;
}
if (targetNode === undefined) {
console.error('APA01 Target node not found for edge', edge);
return;
}
// Fallback positions if nodes not found
const startPos = sourceNode ? { x: sourceNode.x, y: sourceNode.y } : { x: 0, y: 0 };
const endPos = targetNode ? { x: targetNode.x, y: targetNode.y } : { x: 0, y: 0 };
// Calculate midpoint for edge // Calculate midpoint for edge
const midX = (startPos.x + endPos.x) / 2; const midX = (startPos.x + endPos.x) / 2;
const midY = (startPos.y + endPos.y) / 2; const midY = (startPos.y + endPos.y) / 2;
const points = [startPos];
if (sourceNode.section === 'left') {
points.push({
x: sourceNode.x - (sourceNode.width ?? 0) / 2 - intersectionShift,
y: sourceNode.y,
});
} else if (sourceNode.section === 'right') {
points.push({
x: sourceNode.x + (sourceNode.width ?? 0) / 2 + intersectionShift,
y: sourceNode.y,
});
}
if (targetNode.section === 'left') {
points.push({
x: targetNode.x + (targetNode.width ?? 0) / 2 + intersectionShift,
y: targetNode.y,
});
} else if (targetNode.section === 'right') {
points.push({
x: targetNode.x - (targetNode.width ?? 0) / 2 - intersectionShift,
y: targetNode.y,
});
}
points.push(endPos);
return { return {
id: edge.id, id: edge.id,
source: edge.start || '', source: edge.start ?? '',
target: edge.end || '', target: edge.end ?? '',
startX: startPos.x, startX: startPos.x,
startY: startPos.y, startY: startPos.y,
midX, midX,
midY, midY,
endX: endPos.x, endX: endPos.x,
endY: endPos.y, endY: endPos.y,
points,
// Include tree membership information
sourceSection: sourceNode?.section,
targetSection: targetNode?.section,
// Include node dimensions for precise edge point calculations
sourceWidth: sourceNode?.width,
sourceHeight: sourceNode?.height,
targetWidth: targetNode?.width,
targetHeight: targetNode?.height,
}; };
}); });
} }

View File

@@ -152,15 +152,17 @@ export const render = async (
const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id);
if (positionedEdge) { if (positionedEdge) {
console.debug('APA01 positionedEdge', positionedEdge);
// Create edge path with positioned coordinates // Create edge path with positioned coordinates
const edgeWithPath = { const edgeWithPath = {
...edge, ...edge,
points: [ points: positionedEdge.points,
{ x: positionedEdge.startX, y: positionedEdge.startY }, // points: [
{ x: positionedEdge.endX, y: positionedEdge.endY }, // { x: positionedEdge.startX, y: positionedEdge.startY },
], // { x: positionedEdge.endX, y: positionedEdge.endY },
// ],
}; };
// debugger;
// Insert the edge path // Insert the edge path
const paths = insertEdge( const paths = insertEdge(
edgePaths, edgePaths,

View File

@@ -7,6 +7,13 @@ export interface PositionedNode {
id: string; id: string;
x: number; x: number;
y: number; y: number;
// Tree membership information for edge calculations
section?: 'root' | 'left' | 'right';
// Original node dimensions for edge point calculations
width?: number;
height?: number;
// Reference to original node for additional data
originalNode?: Node;
[key: string]: unknown; // Allow additional properties [key: string]: unknown; // Allow additional properties
} }
@@ -23,6 +30,14 @@ export interface PositionedEdge {
midY: number; midY: number;
endX: number; endX: number;
endY: number; endY: number;
// Tree membership information for edge styling/routing
sourceSection?: 'root' | 'left' | 'right';
targetSection?: 'root' | 'left' | 'right';
// Node dimensions for precise edge point calculations
sourceWidth?: number;
sourceHeight?: number;
targetWidth?: number;
targetHeight?: number;
[key: string]: unknown; // Allow additional properties [key: string]: unknown; // Allow additional properties
} }