# 🌳 带尺寸多叉树分层递归布局算法详解
这里主要应用与思维导图,树的方向是从左到右
让你的树形结构「严丝合缝」!✨ 本算法可实现:
✅ 父节点垂直居中于子节点区域
✅ 子节点右侧竖直排列不重叠
✅ 支持动态尺寸和折叠状态
✅ O (n) 时间复杂度
# 🧩 核心思想
分为两阶段
- 📏 预计算阶段:自底向上统计子树尺寸
- 🎯 定位阶段:自顶向下递归布局
# 🛠️ 实现
# 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 坐标的开始处开始排列 (待解决)
- 缓存子树的尺寸,避免重复计算
- 增量更新节点位置,减少重绘次数
# 🌟 适用场景
- 思维导图工具 🧠
- 文件目录可视化 📂
- 组织架构图 👥
💡 小贴士:调整 horizontalSpacing
和 verticalSpacing
可获得不同风格的布局效果!🎨
✨优化更新: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
¢er
&top-to-bottom
树布局结构,现在一共有四种树布局结构
更新原因:
- 原来的布局结构太单一
步骤:
- 状态管理中添加布局结构选择
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'; // 新增布局风格属性 | |
} |
- 对于 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); | |
} | |
}) | |
}); | |
}, |
- 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; |
- 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; |
- top-to-bottom 的布局代码
只需要将原来的布局算法,宽度改为高度,高度改为宽度即可。核心的计算思想也是不变,先尺寸再布局,也可以看成是left-to-right
顺时针旋改变x
和y
坐标得来的。
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
结构的布局,当然!后续也可以添加 时间轴布局结构
& 鱼骨图
& 力导向
等等许多结构布局