@mermaid-js/layout-tidy-tree
Version:
Tidy-tree layout engine for mermaid
583 lines (579 loc) • 19.5 kB
JavaScript
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
// src/layout.ts
import { BoundingBox, Layout } from "non-layered-tidy-tree-layout";
function executeTidyTreeLayout(data) {
let intersectionShift = 50;
return new Promise((resolve, reject) => {
try {
if (!data.nodes || !Array.isArray(data.nodes) || data.nodes.length === 0) {
throw new Error("No nodes found in layout data");
}
if (!data.edges || !Array.isArray(data.edges)) {
data.edges = [];
}
const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data);
const gap = 20;
const bottomPadding = 40;
intersectionShift = 30;
const bb = new BoundingBox(gap, bottomPadding);
const layout = new Layout(bb);
let leftResult = null;
let rightResult = null;
if (leftTree) {
const leftLayoutResult = layout.layout(leftTree);
leftResult = leftLayoutResult.result;
}
if (rightTree) {
const rightLayoutResult = layout.layout(rightTree);
rightResult = rightLayoutResult.result;
}
const positionedNodes = combineAndPositionTrees(rootNode, leftResult, rightResult);
const positionedEdges = calculateEdgePositions(
data.edges,
positionedNodes,
intersectionShift
);
resolve({
nodes: positionedNodes,
edges: positionedEdges
});
} catch (error) {
reject(error);
}
});
}
__name(executeTidyTreeLayout, "executeTidyTreeLayout");
function convertToDualTreeFormat(data) {
const { nodes, edges } = data;
const nodeMap = /* @__PURE__ */ new Map();
nodes.forEach((node) => nodeMap.set(node.id, node));
const children = /* @__PURE__ */ new Map();
const parents = /* @__PURE__ */ new Map();
edges.forEach((edge) => {
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);
}
});
const rootNodeData = nodes.find((node) => !parents.has(node.id));
if (!rootNodeData && nodes.length === 0) {
throw new Error("No nodes available to create tree");
}
const actualRoot = rootNodeData ?? nodes[0];
const rootNode = {
id: actualRoot.id,
width: actualRoot.width ?? 100,
height: actualRoot.height ?? 50,
_originalNode: actualRoot
};
const rootChildren = children.get(actualRoot.id) ?? [];
const leftChildren = [];
const rightChildren = [];
rootChildren.forEach((childId, index) => {
if (index % 2 === 0) {
leftChildren.push(childId);
} else {
rightChildren.push(childId);
}
});
const leftTree = leftChildren.length > 0 ? buildSubTree(leftChildren, children, nodeMap) : null;
const rightTree = rightChildren.length > 0 ? buildSubTree(rightChildren, children, nodeMap) : null;
return { leftTree, rightTree, rootNode };
}
__name(convertToDualTreeFormat, "convertToDualTreeFormat");
function buildSubTree(rootChildren, children, nodeMap) {
const virtualRoot = {
id: `virtual-root-${Math.random()}`,
width: 1,
height: 1,
children: rootChildren.map((childId) => nodeMap.get(childId)).filter((child) => child !== void 0).map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap))
};
return virtualRoot;
}
__name(buildSubTree, "buildSubTree");
function convertNodeToTidyTreeTransposed(node, children, nodeMap) {
const childIds = children.get(node.id) ?? [];
const childNodes = childIds.map((childId) => nodeMap.get(childId)).filter((child) => child !== void 0).map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap));
return {
id: node.id,
width: node.height ?? 50,
height: node.width ?? 100,
children: childNodes.length > 0 ? childNodes : void 0,
_originalNode: node
};
}
__name(convertNodeToTidyTreeTransposed, "convertNodeToTidyTreeTransposed");
function combineAndPositionTrees(rootNode, leftResult, rightResult) {
const positionedNodes = [];
const rootX = 0;
const rootY = 0;
const treeSpacing = rootNode.width / 2 + 30;
const leftTreeNodes = [];
const rightTreeNodes = [];
if (leftResult?.children) {
positionLeftTreeBidirectional(leftResult.children, leftTreeNodes, rootX - treeSpacing, rootY);
}
if (rightResult?.children) {
positionRightTreeBidirectional(
rightResult.children,
rightTreeNodes,
rootX + treeSpacing,
rootY
);
}
let leftTreeCenterY = 0;
let rightTreeCenterY = 0;
if (leftTreeNodes.length > 0) {
const leftTreeXPositions = [...new Set(leftTreeNodes.map((node) => node.x))].sort(
(a, b) => b - a
);
const firstLevelLeftX = leftTreeXPositions[0];
const firstLevelLeftNodes = leftTreeNodes.filter((node) => node.x === firstLevelLeftX);
if (firstLevelLeftNodes.length > 0) {
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;
}
}
if (rightTreeNodes.length > 0) {
const rightTreeXPositions = [...new Set(rightTreeNodes.map((node) => node.x))].sort(
(a, b) => a - b
);
const firstLevelRightX = rightTreeXPositions[0];
const firstLevelRightNodes = rightTreeNodes.filter((node) => node.x === firstLevelRightX);
if (firstLevelRightNodes.length > 0) {
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;
}
}
const leftTreeOffset = -leftTreeCenterY;
const rightTreeOffset = -rightTreeCenterY;
positionedNodes.push({
id: String(rootNode.id),
x: rootX,
y: rootY + 20,
section: "root",
width: rootNode._originalNode?.width ?? rootNode.width,
height: rootNode._originalNode?.height ?? rootNode.height,
originalNode: rootNode._originalNode
});
const leftTreeNodesWithOffset = leftTreeNodes.map((node) => ({
id: node.id,
x: node.x - (node.width ?? 0) / 2,
y: node.y + leftTreeOffset + (node.height ?? 0) / 2,
section: "left",
width: node.width,
height: node.height,
originalNode: node.originalNode
}));
const rightTreeNodesWithOffset = rightTreeNodes.map((node) => ({
id: node.id,
x: node.x + (node.width ?? 0) / 2,
y: node.y + rightTreeOffset + (node.height ?? 0) / 2,
section: "right",
width: node.width,
height: node.height,
originalNode: node.originalNode
}));
positionedNodes.push(...leftTreeNodesWithOffset);
positionedNodes.push(...rightTreeNodesWithOffset);
return positionedNodes;
}
__name(combineAndPositionTrees, "combineAndPositionTrees");
function positionLeftTreeBidirectional(nodes, positionedNodes, offsetX, offsetY) {
nodes.forEach((node) => {
const distanceFromRoot = node.y ?? 0;
const verticalPosition = node.x ?? 0;
const originalWidth = node._originalNode?.width ?? 100;
const originalHeight = node._originalNode?.height ?? 50;
const adjustedY = offsetY + verticalPosition;
positionedNodes.push({
id: String(node.id),
x: offsetX - distanceFromRoot,
y: adjustedY,
width: originalWidth,
height: originalHeight,
originalNode: node._originalNode
});
if (node.children) {
positionLeftTreeBidirectional(node.children, positionedNodes, offsetX, offsetY);
}
});
}
__name(positionLeftTreeBidirectional, "positionLeftTreeBidirectional");
function positionRightTreeBidirectional(nodes, positionedNodes, offsetX, offsetY) {
nodes.forEach((node) => {
const distanceFromRoot = node.y ?? 0;
const verticalPosition = node.x ?? 0;
const originalWidth = node._originalNode?.width ?? 100;
const originalHeight = node._originalNode?.height ?? 50;
const adjustedY = offsetY + verticalPosition;
positionedNodes.push({
id: String(node.id),
x: offsetX + distanceFromRoot,
y: adjustedY,
width: originalWidth,
height: originalHeight,
originalNode: node._originalNode
});
if (node.children) {
positionRightTreeBidirectional(node.children, positionedNodes, offsetX, offsetY);
}
});
}
__name(positionRightTreeBidirectional, "positionRightTreeBidirectional");
function computeCircleEdgeIntersection(circle, lineStart, lineEnd) {
const radius = Math.min(circle.width, circle.height) / 2;
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length === 0) {
return lineStart;
}
const nx = dx / length;
const ny = dy / length;
return {
x: circle.x - nx * radius,
y: circle.y - ny * radius
};
}
__name(computeCircleEdgeIntersection, "computeCircleEdgeIntersection");
function intersection(node, outsidePoint, insidePoint) {
const x = node.x;
const y = node.y;
if (!node.width || !node.height) {
return { x: outsidePoint.x, y: outsidePoint.y };
}
const dx = Math.abs(x - insidePoint.x);
const w = node?.width / 2;
let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx;
const h = node.height / 2;
const Q = Math.abs(outsidePoint.y - insidePoint.y);
const R = Math.abs(outsidePoint.x - insidePoint.x);
if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) {
const q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y;
r = R * q / Q;
const res = {
x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r,
y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q
};
if (r === 0) {
res.x = outsidePoint.x;
res.y = outsidePoint.y;
}
if (R === 0) {
res.x = outsidePoint.x;
}
if (Q === 0) {
res.y = outsidePoint.y;
}
return res;
} else {
if (insidePoint.x < outsidePoint.x) {
r = outsidePoint.x - w - x;
} else {
r = x - w - outsidePoint.x;
}
const q = Q * r / R;
let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r;
let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q;
if (r === 0) {
_x = outsidePoint.x;
_y = outsidePoint.y;
}
if (R === 0) {
_x = outsidePoint.x;
}
if (Q === 0) {
_y = outsidePoint.y;
}
return { x: _x, y: _y };
}
}
__name(intersection, "intersection");
function calculateEdgePositions(edges, positionedNodes, intersectionShift) {
const nodeInfo = /* @__PURE__ */ new Map();
positionedNodes.forEach((node) => {
nodeInfo.set(node.id, node);
});
return edges.map((edge) => {
const sourceNode = nodeInfo.get(edge.start ?? "");
const targetNode = nodeInfo.get(edge.end ?? "");
if (!sourceNode || !targetNode) {
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: void 0,
targetSection: void 0,
sourceWidth: void 0,
sourceHeight: void 0,
targetWidth: void 0,
targetHeight: void 0
};
}
const sourceCenter = { x: sourceNode.x, y: sourceNode.y };
const targetCenter = { x: targetNode.x, y: targetNode.y };
const isSourceRound = ["circle", "cloud", "bang"].includes(
sourceNode.originalNode?.shape ?? ""
);
const isTargetRound = ["circle", "cloud", "bang"].includes(
targetNode.originalNode?.shape ?? ""
);
let startPos = isSourceRound ? computeCircleEdgeIntersection(
{
x: sourceNode.x,
y: sourceNode.y,
width: sourceNode.width ?? 100,
height: sourceNode.height ?? 100
},
targetCenter,
sourceCenter
) : intersection(sourceNode, sourceCenter, targetCenter);
let endPos = isTargetRound ? computeCircleEdgeIntersection(
{
x: targetNode.x,
y: targetNode.y,
width: targetNode.width ?? 100,
height: targetNode.height ?? 100
},
sourceCenter,
targetCenter
) : intersection(targetNode, targetCenter, sourceCenter);
const midX = (startPos.x + endPos.x) / 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);
const secondPoint = points.length > 1 ? points[1] : targetCenter;
startPos = isSourceRound ? computeCircleEdgeIntersection(
{
x: sourceNode.x,
y: sourceNode.y,
width: sourceNode.width ?? 100,
height: sourceNode.height ?? 100
},
secondPoint,
sourceCenter
) : intersection(sourceNode, secondPoint, sourceCenter);
points[0] = startPos;
const secondLastPoint = points.length > 1 ? points[points.length - 2] : sourceCenter;
endPos = isTargetRound ? computeCircleEdgeIntersection(
{
x: targetNode.x,
y: targetNode.y,
width: targetNode.width ?? 100,
height: targetNode.height ?? 100
},
secondLastPoint,
targetCenter
) : intersection(targetNode, secondLastPoint, targetCenter);
points[points.length - 1] = endPos;
return {
id: edge.id,
source: edge.start ?? "",
target: edge.end ?? "",
startX: startPos.x,
startY: startPos.y,
midX,
midY,
endX: endPos.x,
endY: endPos.y,
points,
sourceSection: sourceNode?.section,
targetSection: targetNode?.section,
sourceWidth: sourceNode?.width,
sourceHeight: sourceNode?.height,
targetWidth: targetNode?.width,
targetHeight: targetNode?.height
};
});
}
__name(calculateEdgePositions, "calculateEdgePositions");
function validateLayoutData(data) {
if (!data) {
throw new Error("Layout data is required");
}
if (!data.config) {
throw new Error("Configuration is required in layout data");
}
if (!Array.isArray(data.nodes)) {
throw new Error("Nodes array is required in layout data");
}
if (!Array.isArray(data.edges)) {
throw new Error("Edges array is required in layout data");
}
return true;
}
__name(validateLayoutData, "validateLayoutData");
// src/render.ts
var render = /* @__PURE__ */ __name(async (data4Layout, svg, {
insertCluster,
insertEdge,
insertEdgeLabel,
insertMarkers,
insertNode,
log,
positionEdgeLabel
}, { algorithm: _algorithm }) => {
const nodeDb = {};
const clusterDb = {};
const element = svg.select("g");
insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId);
const subGraphsEl = element.insert("g").attr("class", "subgraphs");
const edgePaths = element.insert("g").attr("class", "edgePaths");
const edgeLabels = element.insert("g").attr("class", "edgeLabels");
const nodes = element.insert("g").attr("class", "nodes");
log.debug("Inserting nodes into DOM for dimension calculation");
await Promise.all(
data4Layout.nodes.map(async (node) => {
if (node.isGroup) {
const clusterNode = {
...node,
id: node.id,
width: node.width,
height: node.height
};
clusterDb[node.id] = clusterNode;
nodeDb[node.id] = clusterNode;
await insertCluster(subGraphsEl, node);
} else {
const nodeWithPosition = {
...node,
id: node.id,
width: node.width,
height: node.height
};
nodeDb[node.id] = nodeWithPosition;
const nodeEl = await insertNode(nodes, node, {
config: data4Layout.config,
dir: data4Layout.direction || "TB"
});
const boundingBox = nodeEl.node().getBBox();
nodeWithPosition.width = boundingBox.width;
nodeWithPosition.height = boundingBox.height;
nodeWithPosition.domId = nodeEl;
log.debug(`Node ${node.id} dimensions: ${boundingBox.width}x${boundingBox.height}`);
}
})
);
log.debug("Running bidirectional tidy-tree layout algorithm");
const updatedLayoutData = {
...data4Layout,
nodes: data4Layout.nodes.map((node) => {
const nodeWithDimensions = nodeDb[node.id];
return {
...node,
width: nodeWithDimensions.width ?? node.width ?? 100,
height: nodeWithDimensions.height ?? node.height ?? 50
};
})
};
const layoutResult = await executeTidyTreeLayout(updatedLayoutData);
log.debug("Positioning nodes based on bidirectional layout results");
layoutResult.nodes.forEach((positionedNode) => {
const node = nodeDb[positionedNode.id];
if (node?.domId) {
node.domId.attr("transform", `translate(${positionedNode.x}, ${positionedNode.y})`);
node.x = positionedNode.x;
node.y = positionedNode.y;
log.debug(`Positioned node ${node.id} at (${positionedNode.x}, ${positionedNode.y})`);
}
});
log.debug("Inserting and positioning edges");
await Promise.all(
data4Layout.edges.map(async (edge) => {
await insertEdgeLabel(edgeLabels, edge);
const startNode = nodeDb[edge.start ?? ""];
const endNode = nodeDb[edge.end ?? ""];
if (startNode && endNode) {
const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id);
if (positionedEdge) {
log.debug("APA01 positionedEdge", positionedEdge);
const edgeWithPath = {
...edge,
points: positionedEdge.points
};
const paths = insertEdge(
edgePaths,
edgeWithPath,
clusterDb,
data4Layout.type,
startNode,
endNode,
data4Layout.diagramId
);
positionEdgeLabel(edgeWithPath, paths);
} else {
const edgeWithPath = {
...edge,
points: [
{ x: startNode.x ?? 0, y: startNode.y ?? 0 },
{ x: endNode.x ?? 0, y: endNode.y ?? 0 }
]
};
const paths = insertEdge(
edgePaths,
edgeWithPath,
clusterDb,
data4Layout.type,
startNode,
endNode,
data4Layout.diagramId
);
positionEdgeLabel(edgeWithPath, paths);
}
}
})
);
log.debug("Bidirectional tidy-tree rendering completed");
}, "render");
export {
__name,
executeTidyTreeLayout,
validateLayoutData,
render
};