# 🌳 带尺寸多叉树分层递归布局算法详解

这里主要应用与思维导图,树的方向是从左到右
让你的树形结构「严丝合缝」!✨ 本算法可实现:
✅ 父节点垂直居中于子节点区域
✅ 子节点右侧竖直排列不重叠
✅ 支持动态尺寸和折叠状态
✅ O (n) 时间复杂度


# 🧩 核心思想

分为两阶段

  1. 📏 预计算阶段:自底向上统计子树尺寸
  2. 🎯 定位阶段:自顶向下递归布局

# 🛠️ 实现

# 1. 📦 数据结构定义

interface TreeNode {
  id: string;
  position: [number, number];  // 节点中心坐标
  size: [number, number];      // [宽度,高度]
  children: string[];          // 子节点 ID 列表
  collapsed: boolean;          // 折叠状态
}

# 2. ⚙️ 配置参数

const CONFIG = {
  horizontalSpacing: 50,  //  父子水平间距
  verticalSpacing: 30     //  兄弟垂直间距
};

# 3. 📐 预计算子树尺寸

通过递归的方式,计算每个节点子树的尺寸 (包括自身)
算法原理

  • 自底向上递归:从叶子节点开始,逐步向上计算每个节点的子树尺寸。
  • 考虑折叠状态:如果节点被折叠,它的子节点将不参与布局,直接返回节点自身的尺寸。
  • 合并子节点尺寸:对于非叶子节点,需要计算其所有子节点的高度总和 (并与自身高度取最大值),并找出最大子节点的宽度。

图解连接:计算尺寸

/**
 * 预计每个节点树尺寸,并返回总宽度和总高度 (后序遍历)
 * @param nodeId 当前节点 ID
 * @param nodes 所有节点对象
 * @param sizes 每个节点树尺寸的一个映射
 * @returns 当前节点树的尺寸 {width: number, height: number}
 */
const calculateSubtreeSize = (
  nodeId: string,
  nodes: Record<string, TreeNode>,
  sizes: Map<string, { width: number, height: number }>
): { width: number, height: number } => {
  const node = nodes[nodeId];
  
  // 折叠状态或叶子节点
  if (node.collapsed || node.children.length === 0) {
    const size = { width: getNodeWidth(node), height: getNodeHeight(node) };
    sizes.set(nodeId, size);
    return size;
  }
  // 递归计算子节点尺寸
  let totalChildHeight = 0;
  let maxChildWidth = 0;
  
  node.children.forEach(childId => {
    const childSize = calculateSubtreeSize(childId, nodes, sizes);
    totalChildHeight += childSize.height;
    maxChildWidth = Math.max(maxChildWidth, childSize.width);
  });
  // 计算总高度(包含间距)
  const subtreeHeight = totalChildHeight + (node.children.length - 1) * verticalSpacing;
  // 当前节点宽度 = 自身宽度 + 最大子节点宽度 + 水平间距
  const subtreeWidth = getNodeWidth(node) + maxChildWidth + horizontalSpacing;
  // 计算总高度(取自身高度和子树高度中的较大值)
  const totalHeight = Math.max(getNodeHeight(node), subtreeHeight);
  // 设置当前节点树尺寸
  sizes.set(nodeId, { width: subtreeWidth, height: totalHeight });
  
  return { width: subtreeWidth, height: totalHeight };
};

# 4. 📈 节点树布局

根据计算出的子树尺寸,递归地设置每个节点的位置
算法原理

  • 自顶向下递归:从根节点开始,根据父节点的位置和子树尺寸,确定每个子节点的位置。
  • 垂直居中对齐:为了保持视觉上的平衡,子节点相对于父节点垂直居中对齐。
  • 忽略折叠节点:如果某个节点被折叠,则不处理其子节点。

兄弟节点需要累加垂直位移才保证不会重叠
并且注意父节点是更具节点树的高度来确定垂直位置的偏移量,而子节点则是根据自身高度来确定垂直位置的偏移量。

图解:布局

/**
 * 递归设置每个节点的位置 (前序遍历)
 * @param nodeId 当前节点的 ID
 * @param nodes 所有节点对象
 * @param sizes 每个节点树尺寸的隐射
 * @param parentX 父节点的 X 坐标
 * @param parentY 父节点的 Y 坐标
 */
const layoutNode = (
  nodeId: string,
  nodes: Record<string, TreeNode>,
  sizes: Map<string, { width: number, height: number }>,
  parentX: number,
  parentY: number
) => {
  const node = nodes[nodeId];
  const nodeSize = sizes.get(nodeId)!;
  
  // 设置当前节点位置(根据该点的节点树的尺寸,垂直居中)
  const currentX = parentX;
  const currentY = parentY + nodeSize.height / 2 - getNodeHeight(node) / 2;
  useMindmapStore.getState().actions.setNodePosition(nodeId, [currentX, currentY]);
  // 折叠状态不处理子节点
  if (node.collapsed) return;
  // 计算子节点起始位置
  let childY = parentY;
  node.children.forEach(childId => {
    const childSize = sizes.get(childId)!;
    
    // 子节点水平位置
    const childX = currentX + getNodeWidth(node) + horizontalSpacing;
    
    // 递归布局
    layoutNode(childId, nodes, sizes, childX, childY);
    
    // 更新累积 Y 坐标(包含间距)
    childY += childSize.height + verticalSpacing;
  });
};

# ⚡优化建议

  • 如果父节点的高度就是该节点树的高度时,子节点会在父节点 Y 坐标的开始处开始排列 (待解决)
  • 缓存子树的尺寸,避免重复计算
  • 增量更新节点位置,减少重绘次数

# 🌟 适用场景

  • 思维导图工具 🧠
  • 文件目录可视化 📂
  • 组织架构图 👥

💡 小贴士:调整 horizontalSpacingverticalSpacing 可获得不同风格的布局效果!🎨


✨优化更新:2025-04-13
优化内容:

  • 将布局代码放到主函数中,用 map 去记录更新后的节点位置,布局结束后统一更新节点位置,减少渲染次数。

优化原因:

  • 原来的代码在布局的时候每次算出的位置都直接调用 setNodePosition 去更新,会导致组件重渲染多次,导致性能下降。

实测可以大大减少重绘次数,提升性能。

代码:

/**
 * 计算整个树的布局
 * 
 */
const calculateTreeLayout = () => {
  const { nodes } = useMindmapStore.getState();
  const rootNode = nodes['root'];
  const originalRootX = rootNode.position[0];
  const originalRootY = rootNode.position[1];
  // 1. 创建临时位置存储对象
  const newPositions: Record<string, [number, number]> = {};
  // 2. 清空位置
  Object.keys(nodes).forEach(id => {
    newPositions[id] = [0, 0];
  });
  // 计算子树尺寸
  const subtreeSizes = new Map<string, { width: number, height: number }>();
  calculateSubtreeSize(rootNode.id, nodes, subtreeSizes);
  // 3. 修改后的布局函数(不再直接更新状态)
  const layoutNode = (
    nodeId: string,
    parentX: number,
    parentY: number
  ) => {
    const node = nodes[nodeId];
    const nodeSize = subtreeSizes.get(nodeId)!;
    const currentX = parentX;
    const currentY = parentY + nodeSize.height / 2 - getNodeHeight(node) / 2;
    newPositions[nodeId] = [currentX, currentY]; // 存储到临时对象
    if (node.collapsed) return;
    let childY = parentY;
    node.children.forEach(childId => {
      const childSize = subtreeSizes.get(childId)!;
      const childX = currentX + getNodeWidth(node) + horizontalSpacing;
      layoutNode(childId, childX, childY);
      childY += childSize.height + verticalSpacing;
    });
  };
  layoutNode(rootNode.id, 0, 0);
  // 4. 计算位置偏移并批量更新
  const computedRootX = newPositions[rootNode.id][0];
  const computedRootY = newPositions[rootNode.id][1];
  const dx = computedRootX - originalRootX;
  const dy = computedRootY - originalRootY;
  // 应用偏移并准备最终位置
  const finalPositions: Record<string, [number, number]> = {};
  Object.keys(newPositions).forEach(id => {
    const [x, y] = newPositions[id];
    finalPositions[id] = [x - dx, y - dy];
  });
  // 5. 一次性更新所有位置
  useMindmapStore.getState().actions.setNodePositions(finalPositions);
};

✨优化更新:2025-04-16
更新内容:

  • 使用思维导图的树布局算法,实现了 right-to-left & center & top-to-bottom 树布局结构,现在一共有四种树布局结构

更新原因:

  • 原来的布局结构太单一

步骤:

  1. 状态管理中添加布局结构选择
export interface Node {
    id: string;
    text: string;
    position: [number, number];
    children: string[];
    size: [number, number]; // 新增尺寸字段 [width, height]
    collapsed: boolean;     // 新增折叠状态
    direction?: 'left' | 'right' | 'none'; // 新增方向属性 (只在 center 布局中使用)
}
export type State = {
    nodes: Record<string, Node>;
    connections: string[];
    selectedNodeId: string | null;
    layoutStyle: 'left-to-right' | 'right-to-left' | 'center' | 'top-to-bottom'; // 新增布局风格属性
}
  1. 对于 center 布局需要一个特定的 direction 属性
    center 布局我采用的是只对于根节点的子节点进行左右区分,一半在左一半在右边,其他的子节点就直接继承其父节点的 direction 属性。这样就可以划分出左子树和右子树。
    当然!在每次对根节点进行操作时都需要从新计算 direction 属性.
    因此在状态管理中添加以下函数:
// 更新 center 布局的 direction
updateChildrenDirections: () => {
    set((state) => {
        const updateNodeDirection = (nodeId: string, parentDirection?: 'left' | 'right' | 'none') => {
            const node = state.nodes[nodeId];
            if (!node) return;
            // 如果父节点有方向,继承父节点的方向
            if (parentDirection) {
                node.direction = parentDirection;
            }
            // 递归更新子节点的方向
            node.children.forEach((childId) => {
                updateNodeDirection(childId, node.direction);
            })
        }
        const rootNode = state.nodes['root'];
        // console.log(JSON.parse(JSON.stringify(rootNode.children)))
        if (!rootNode || !Array.isArray(rootNode.children)) return;
        rootNode.children.forEach((childId, index) => {
            const child = state.nodes[childId];
            if (child) {
                if (state.layoutStyle === 'center') {
                    child.direction =
                        index < rootNode.children.length / 2 ? 'left' : 'right';
                } else if (state.layoutStyle === 'left-to-right') {
                    child.direction = 'right';
                } else if (state.layoutStyle === 'right-to-left') {
                    child.direction = 'left';
                }
            }
        });
        // console.log(JSON.parse(JSON.stringify(rooFtNode.children)))
        // 调用函数更新所有子节点的方向
        rootNode.children.forEach((childId) => {
            const child = state.nodes[childId];
            if (child) {
                updateNodeDirection(childId, child.direction);
            }
        })
    });
},
  1. left-to-right & right-to-left 布局代码
    只需要将之前的布局代码区分为左右,修改 childX 的计算方法即可实现
import { useMindmapStore } from '../store/useMindmapStore';
interface TreeNode {
  id: string;
  position: [number, number];
  size: [number, number];
  children: string[];
  collapsed: boolean;
}
// 辅助函数
const getNodeWidth = (node: TreeNode) => node.size[0];
const getNodeHeight = (node: TreeNode) => node.size[1];
const horizontalSpacing = 100;  // 父子节点水平间距
const verticalSpacing = 30;    // 兄弟节点垂直间距
/**
 * 计算整个树的布局
 * 
 */
const calculateTreeLayout = () => {
  const { nodes } = useMindmapStore.getState();
  const layoutStyle = useMindmapStore.getState().layoutStyle;
  const rootNode = nodes['root'];
  const originalRootX = rootNode.position[0];
  const originalRootY = rootNode.position[1];
  // 1. 创建临时位置存储对象
  const newPositions: Record<string, [number, number]> = {};
  // 2. 修改清空位置的方式
  Object.keys(nodes).forEach(id => {
    newPositions[id] = [0, 0];
  });
  // 计算子树尺寸
  const subtreeSizes = new Map<string, { width: number, height: number }>();
  calculateSubtreeSize(rootNode.id, nodes, subtreeSizes);
  // 3. 修改后的布局函数(不再直接更新状态)
  const layoutNode = (
    nodeId: string,
    parentX: number,
    parentY: number
  ) => {
    const node = nodes[nodeId];
    const nodeSize = subtreeSizes.get(nodeId)!;
    const currentX = parentX;
    const currentY = parentY + nodeSize.height / 2 - getNodeHeight(node) / 2;
    newPositions[nodeId] = [currentX, currentY]; // 存储到临时对象
    if (node.collapsed) return;
    let childY = parentY;
    node.children.forEach(childId => {
      const childSize = subtreeSizes.get(childId)!;
      let childX = 0;
      if(layoutStyle === 'left-to-right') { // 加
        childX = currentX + getNodeWidth(node) + horizontalSpacing;
      } else if(layoutStyle === 'right-to-left') { // 减
        childX = currentX - getNodeWidth(nodes[childId]) - horizontalSpacing;
      }
      layoutNode(childId, childX, childY);
      childY += childSize.height + verticalSpacing;
    });
  };
  layoutNode(rootNode.id, 0, 0);
  // 4. 计算位置偏移并批量更新
  const computedRootX = newPositions[rootNode.id][0];
  const computedRootY = newPositions[rootNode.id][1];
  const dx = computedRootX - originalRootX;
  const dy = computedRootY - originalRootY;
  // 应用偏移并准备最终位置
  const finalPositions: Record<string, [number, number]> = {};
  Object.keys(newPositions).forEach(id => {
    const [x, y] = newPositions[id];
    finalPositions[id] = [x - dx, y - dy];
  });
  // 5. 一次性更新所有位置
  useMindmapStore.getState().actions.setNodePositions(finalPositions);
};
/**
 * 预计每个节点树尺寸,并返回总宽度和总高度 (后序遍历)
 * @param nodeId 当前节点 ID
 * @param nodes 所有节点对象
 * @param sizes 每个节点树尺寸的一个映射
 * @returns 当前节点树的尺寸 {width: number, height: number}
 */
const calculateSubtreeSize = (
  nodeId: string,
  nodes: Record<string, TreeNode>,
  sizes: Map<string, { width: number, height: number }>
): { width: number, height: number } => {
  const node = nodes[nodeId];
  
  // 折叠状态或叶子节点
  if (node.collapsed || node.children.length === 0) {
    const size = { width: getNodeWidth(node), height: getNodeHeight(node) };
    sizes.set(nodeId, size);
    return size;
  }
  // 递归计算子节点尺寸
  let totalChildHeight = 0;
  let maxChildWidth = 0;
  
  node.children.forEach(childId => {
    const childSize = calculateSubtreeSize(childId, nodes, sizes);
    totalChildHeight += childSize.height;
    maxChildWidth = Math.max(maxChildWidth, childSize.width);
  });
  // 计算总高度(包含间距)
  const subtreeHeight = totalChildHeight + (node.children.length - 1) * verticalSpacing;
  // 当前节点宽度 = 自身宽度 + 最大子节点宽度 + 水平间距
  const subtreeWidth = getNodeWidth(node) + maxChildWidth + horizontalSpacing;
  // 计算总高度(取自身高度和子树高度中的较大值)
  const totalHeight = Math.max(getNodeHeight(node), subtreeHeight);
  // 设置当前节点树尺寸
  sizes.set(nodeId, { width: subtreeWidth, height: totalHeight });
  
  return { width: subtreeWidth, height: totalHeight };
};
export default calculateTreeLayout;
  1. center 布局代码
    划分为左子树和右子树,左子树按照 right-to-left 布局,右子树按照 left-to-right 布局。注意计算子树尺寸的时候也需要将两个子树分开计算。总的来说就是将左右子树分别执行两次布局算法就可以实现。
import { useMindmapStore } from '../store/useMindmapStore';
interface TreeNode {
    id: string;
    position: [number, number];
    size: [number, number];
    children: string[];
    collapsed: boolean;
    direction?: 'left' | 'right' | 'none'; // 使用 Store 中的 direction 属性
}
// 辅助函数
const getNodeWidth = (node: TreeNode) => node.size[0];
const getNodeHeight = (node: TreeNode) => node.size[1];
const horizontalSpacing = 100;  // 父子节点水平间距
const verticalSpacing = 30;    // 兄弟节点垂直间距
/**
 * 计算整个树的布局(从中间向左右布局)
 */
const calculateCenterLayout = () => {
    const { nodes } = useMindmapStore.getState();
    const rootNode = nodes['root'];
    if(nodes[rootNode.id].collapsed) return;
    const originalRootX = rootNode.position[0];
    const originalRootY = rootNode.position[1];
    // 临时存储每个节点的新位置
    const newPositions: Record<string, [number, number]> = {};
    // 初始化所有节点位置为 [0, 0]
    Object.keys(nodes).forEach(id => {
        newPositions[id] = [0, 0];
    });
    // 子树尺寸缓存(全局共享)
    const subtreeSizes = new Map<string, { width: number, height: number }>();
    // 分离左右子树
    const leftChildren = rootNode.children.filter(childId => nodes[childId].direction === 'left');
    const rightChildren = rootNode.children.filter(childId => nodes[childId].direction === 'right');
    // 布局左侧子树
    const layoutLeftTree = () => {
        calculateSubtreeSize(rootNode.id, nodes, subtreeSizes);
        const layoutTree = (
            nodeId: string,
            parentX: number,
            parentY: number
        ) => {
            const node = nodes[nodeId];
            const nodeSize = subtreeSizes.get(nodeId)!;
            let currentX = parentX;
            if (node.direction === 'left') {
                currentX -= getNodeWidth(node) + horizontalSpacing;
            }
            const currentY = parentY + nodeSize?.height / 2 - getNodeHeight(node) / 2;
            newPositions[nodeId] = [currentX, currentY];
            console.log(newPositions)
            if (node.collapsed || node.children.length === 0) return;
            let childY = parentY;
            node.children.forEach(childId => {
                const childSize = subtreeSizes.get(childId)!;
                layoutTree(childId, currentX, childY);
                childY += childSize.height + verticalSpacing;
            });
        };
        let sum = 0;
        leftChildren.forEach((childId) => {
            const childSize = subtreeSizes.get(childId)!;
            layoutTree(childId, originalRootX, sum);
            sum += childSize?.height + verticalSpacing;
        });
    };
    // 布局右侧子树
    const layoutRightTree = () => {
        calculateSubtreeSize(rootNode.id, nodes, subtreeSizes);
        const layoutTree = (
            nodeId: string,
            parentX: number,
            parentY: number,
            parentId: string
        ) => {
            const node = nodes[nodeId];
            const nodeSize = subtreeSizes.get(nodeId)!;
            let currentX = parentX;
            if (node.direction === 'right') {
                currentX += getNodeWidth(nodes[parentId]) + horizontalSpacing;
            }
            const currentY = parentY + nodeSize?.height / 2 - getNodeHeight(node) / 2;
            newPositions[nodeId] = [currentX, currentY];
            console.log(`Setting position for ${nodeId}:`, newPositions[nodeId]);
            if (node.collapsed || node.children.length === 0) return;
            let childY = parentY;
            node.children.forEach(childId => {
                const childSize = subtreeSizes.get(childId)!;
                layoutTree(childId, currentX, childY, nodeId);
                childY += childSize?.height + verticalSpacing;
            });
        };
        let sum = 0;
        rightChildren.forEach((childId) => {
            const childSize = subtreeSizes.get(childId)!;
            layoutTree(childId, originalRootX, sum, rootNode.id);
            sum += childSize?.height + verticalSpacing;
        });
    };
    // 布局左右子树
    layoutLeftTree();
    layoutRightTree();
    let minY = Infinity;
    let maxY = -Infinity;
    for (const nodeId in newPositions) {
        const y = newPositions[nodeId][1];
        minY = Math.min(minY, y);
        maxY = Math.max(maxY, y + getNodeHeight(nodes[nodeId]));
    }
    if(Number.isNaN(minY) || Number.isNaN(maxY)) {
        minY = 0;
        maxY = 0;
    }
    console.log(`minY: ${minY}, maxY: ${maxY}`)
    // 设置根节点位置
    newPositions[rootNode.id] = [originalRootX, maxY / 2 - getNodeHeight(rootNode) / 2];
    console.log(newPositions[rootNode.id])
    // 4. 计算位置偏移并批量更新
    const computedRootX = newPositions[rootNode.id][0];
    const computedRootY = newPositions[rootNode.id][1];
    
    const dx = computedRootX - originalRootX;
    const dy = computedRootY - originalRootY;
    // 应用偏移并准备最终位置
    const finalPositions: Record<string, [number, number]> = {};
    Object.keys(newPositions).forEach(id => {
        const [x, y] = newPositions[id];
        finalPositions[id] = [x - dx, y - dy];
    });
    // 更新所有节点的位置
    useMindmapStore.getState().actions.setNodePositions(finalPositions);
};
/**
 * 预计每个节点树尺寸,并返回总宽度和总高度 (后序遍历)
 */
const calculateSubtreeSize = (
    nodeId: string,
    nodes: Record<string, TreeNode>,
    sizes: Map<string, { width: number, height: number }>
): { width: number, height: number } => {
    const node = nodes[nodeId];
    if (node.collapsed || node.children.length === 0) {
        const size = { width: getNodeWidth(node), height: getNodeHeight(node) };
        sizes.set(nodeId, size);
        return size;
    }
    let totalHeight = 0;
    let maxWidth = 0;
    node.children.forEach(childId => {
        const childSize = calculateSubtreeSize(childId, nodes, sizes);
        totalHeight += childSize.height + verticalSpacing;
        maxWidth = Math.max(maxWidth, childSize.width);
    });
    const subtreeHeight = Math.max(getNodeHeight(node), totalHeight - verticalSpacing);
    const subtreeWidth = getNodeWidth(node) + maxWidth + horizontalSpacing;
    sizes.set(nodeId, { width: subtreeWidth, height: subtreeHeight });
    return { width: subtreeWidth, height: subtreeHeight };
};
export default calculateCenterLayout;
  1. top-to-bottom 的布局代码
    只需要将原来的布局算法,宽度改为高度,高度改为宽度即可。核心的计算思想也是不变,先尺寸再布局,也可以看成是 left-to-right 顺时针旋改变 xy 坐标得来的。
import { useMindmapStore } from '../store/useMindmapStore';
interface TreeNode {
  id: string;
  position: [number, number];
  size: [number, number];
  children: string[];
  collapsed: boolean;
}
// 辅助函数
const getNodeWidth = (node: TreeNode) => node.size[0];
const getNodeHeight = (node: TreeNode) => node.size[1];
const horizontalSpacing = 30;  // 父子节点水平间距
const verticalSpacing = 100;    // 兄弟节点垂直间距
/**
 * 计算整个树的布局
 * 
 */
const calculateVerticaLayout = () => {
  const { nodes } = useMindmapStore.getState();
  const rootNode = nodes['root'];
  const originalRootX = rootNode.position[0];
  const originalRootY = rootNode.position[1];
  // 1. 创建临时位置存储对象
  const newPositions: Record<string, [number, number]> = {};
  // 2. 修改清空位置的方式
  Object.keys(nodes).forEach(id => {
    newPositions[id] = [0, 0];
  });
  // 计算子树尺寸
  const subtreeSizes = new Map<string, { width: number, height: number }>();
  calculateSubtreeSize(rootNode.id, nodes, subtreeSizes);
  console.log(subtreeSizes)
  // 3. 修改后的布局函数(不再直接更新状态)
  const layoutNode = (
    nodeId: string,
    parentX: number,
    parentY: number
  ) => {
    const node = nodes[nodeId];
    const nodeSize = subtreeSizes.get(nodeId)!;
    const currentX = parentX + nodeSize.width / 2 - getNodeWidth(node) / 2;
    const currentY = parentY;
    newPositions[nodeId] = [currentX, currentY]; // 存储到临时对象
    if (node.collapsed) return;
    let childX = parentX;
    node.children.forEach(childId => {
      const childSize = subtreeSizes.get(childId)!;
      let childY = 0;
      childY = currentY + getNodeHeight(node) + verticalSpacing;
      layoutNode(childId, childX, childY);
      childX += childSize.width + horizontalSpacing;
    });
  };
  layoutNode(rootNode.id, 0, 0);
  // 4. 计算位置偏移并批量更新
  const computedRootX = newPositions[rootNode.id][0];
  const computedRootY = newPositions[rootNode.id][1];
  const dx = computedRootX - originalRootX;
  const dy = computedRootY - originalRootY;
  // 应用偏移并准备最终位置
  const finalPositions: Record<string, [number, number]> = {};
  Object.keys(newPositions).forEach(id => {
    const [x, y] = newPositions[id];
    finalPositions[id] = [x - dx, y - dy];
  });
  // 5. 一次性更新所有位置
  useMindmapStore.getState().actions.setNodePositions(finalPositions);
};
/**
 * 预计每个节点树尺寸,并返回总宽度和总高度 (后序遍历)
 * @param nodeId 当前节点 ID
 * @param nodes 所有节点对象
 * @param sizes 每个节点树尺寸的一个映射
 * @returns 当前节点树的尺寸 {width: number, height: number}
 */
const calculateSubtreeSize = (
  nodeId: string,
  nodes: Record<string, TreeNode>,
  sizes: Map<string, { width: number, height: number }>
): { width: number, height: number } => {
  const node = nodes[nodeId];
  
  // 折叠状态或叶子节点
  if (node.collapsed || node.children.length === 0) {
    const size = { width: getNodeWidth(node), height: getNodeHeight(node) };
    sizes.set(nodeId, size);
    return size;
  }
  // 递归计算子节点尺寸
  let totalChildHeight = 0;
  let maxChildWidth = 0;
  
  node.children.forEach(childId => {
    const childSize = calculateSubtreeSize(childId, nodes, sizes);
    maxChildWidth += childSize.width;
    totalChildHeight = Math.max(totalChildHeight, childSize.height);
  });
  const subtreeHeight = getNodeHeight(node) + totalChildHeight + verticalSpacing;
  const subtreeWidth = maxChildWidth + (node.children.length - 1) * horizontalSpacing;
  const totalWidth = Math.max(getNodeWidth(node), subtreeWidth);
  // 设置当前节点树尺寸
  sizes.set(nodeId, { width: totalWidth, height: subtreeHeight });
  
  return { width: totalWidth, height: subtreeHeight };
};
export default calculateVerticaLayout;

🍾至此,完成了 left-to-right & right-to-left & center & top-to-bottom 结构的布局,当然!后续也可以添加 时间轴布局结构 & 鱼骨图 & 力导向 等等许多结构布局