笔者不久前翻译了一篇介绍树布局算法的文章【译】绘制一棵漂亮的树,但是那篇文章对于算法只是大致介绍了实现的思路,属于启发式文章,虽然有完整的代码,但是要理解起来还是有一定难度,并且要基于该算法实现思维导图也需要进行一定修改,所以本文会通过图解的方式一步步的分解该算法,并最终实现一个思维导图布局。
阅读本文前推荐先阅读一下译文,方便理解文中提到的一些概念。
算法图解
节点类如下,请务必仔细看一下right()
和left()
方法:
// 树节点类 class DrawTree { constructor(tree, parent = null, depth = 0, number = 1) { // 节点名称 this.name = tree.name; // 坐标 this.x = -1; this.y = depth; // 子节点 this.children = tree.children.map((child, index) => { return new DrawTree(child, this, depth + 1, index + 1); }); // 父节点 this.parent = parent; // 线程节点,也就是指向下一个轮廓节点 this.thread = null; // 根据左兄弟定位的x与根据子节点中间定位的x之差 this.mod = 0; // 要么指向自身,要么指向所属树的根 this.ancestor = this; // 记录分摊偏移量 this.change = this.shift = 0; // 最左侧的兄弟节点 this._lmost_sibling = null; // 这是它在兄弟节点中的位置索引 1...n this.number = number; } // 关联了线程则返回线程节点,否则返回最右侧的子节点,也就是树的右轮廓的下一个节点 right() { return ( this.thread || (this.children.length > 0 ? this.children[this.children.length - 1] : null) ); } // 关联了线程则返回线程节点,否则返回最左侧的子节点,也就是树的左轮廓的下一个节点 left() { return ( this.thread || (this.children.length > 0 ? this.children[0] : null) ); } // 获取前一个兄弟节点 left_brother() { let n = null; if (this.parent) { for (let i = 0; i < this.parent.children.length; i++) { let node = this.parent.children[i]; if (node === this) { return n; } else { n = node; } } } return n; } // 获取同一层级第一个兄弟节点,如果第一个是自身,那么返回null get_lmost_sibling() { if ( !this._lmost_sibling && this.parent && this !== this.parent.children[0] ) { this._lmost_sibling = this.parent.children[0]; } return this._lmost_sibling; } // 同一层级第一个兄弟节点 get leftmost_sibling() { return this.get_lmost_sibling(); } }
进入第一次递归,后序遍历树,处理如下:
1.当前节点是叶子节点且无左兄弟,x设为0
2.当前节点是叶子节点且有左兄弟,x为左兄弟的x加上间距,即根据左兄弟定位
3.当前节点非叶子节点且无左兄弟,x为第一个子节点的x加上最后一个子节点的x除以2,即根据子节点定位
4.当前节点非叶子节点且有左兄弟,x为左兄弟的x加上间距,x相对子节点定位的差值保存到mod属性上
// 第一次递归 const firstwalk = (v, distance = 1) => { if (v.children.length === 0) { // 当前节点是叶子节点且存在左兄弟节点,则其x坐标等于其左兄弟的x坐标加上间距distance if (v.leftmost_sibling) { v.x = v.left_brother().x + distance; } else { // 当前节点是叶节点无左兄弟,那么x坐标为0 v.x = 0; } } else { // 后序遍历,先递归子节点 v.children.forEach((child) => { firstwalk(child); }); // 子节点的中点 let midpoint = (v.children[0].x + v.children[v.children.length - 1].x) / 2; // 左兄弟 let w = v.left_brother(); if (w) { // 有左兄弟节点,x坐标设为其左兄弟的x坐标加上间距distance v.x = w.x + distance; // 同时记录下偏移量(x坐标与子节点的中点之差) v.mod = v.x - midpoint; } else { // 没有左兄弟节点,x坐标直接是子节点的中点 v.x = midpoint; } } return v; };
第二次递归将mod
值加到x
上,使父节点仍旧居中于子节点:
// 第二次遍历 const second_walk = (v, m = 0, depth = 0) => { // 初始x值加上所有祖宗节点的mod值(不包括自身的mod) v.x += m; v.y = depth; v.children.forEach((child) => { second_walk(child, m + v.mod, depth + 1); }); };
整个过程也就是两次递归:
const buchheim = (tree) => { let dt = firstwalk(tree); second_walk(dt); return dt; };
第一次递归后的节点位置:
第二次递归后的节点位置:
明显存在冲突的子树可以看到是G
和P
两棵子树,子树P
需要向右移动一定的距离才行,这个距离怎么算呢?
1.进入子树
G
和P
的第二层,找到子树G
在这一层中的最右侧子节点,为F
,找到子树P
在这一层的最左侧子节点,为I
,比较它们的x
坐标,原始x
值加上它们祖先节点的mod
值之和,比较后发现没有交叉,于是进入下一层。
2.进入第三层,同样找到子树
G
在这一层中的最右侧子节点,为E
,子树P
在这一层的最左侧子节点,为J
,比较它们的x
,发现存在交叉,这个差值再加上节点的间隔distance
就是子树P
需要向右移动的距离
3.重复以上,直到最底层。
那么怎么最快速的找到每一层的最左侧或最右侧节点呢,当然可以直接递归,但是时间复杂度就非线性了,所以就引入了一个“线程”的概念(详细了解请阅读译文)。
以上图中的G
节点为例介绍线程的连接过程,从它的子节点C
回来后因为C
没有左兄弟,所以不处理它,进入F
节点递归,从F
节点回来之后紧接着处理F
节点,它存在左兄弟C
,因为每棵树都有内侧和外侧,所以我们设置四个指针:
vInnerLeft
为当前节点的左兄弟节点,vOuterLeft
为当前节点的最左侧的兄弟节点,当然对于F
节点来说,这两个指针都指向C
节点,vInnerRight
和vOuterRight
初始都指向当前节点。
接下来我们就将线程从浅的树的外侧设置到深的树的内侧:
1.因为C
和F
节点都存在子节点,所以这一层还无法判断哪棵树深哪棵树浅,所以就下移一层,同时更新四个指针,这里就会用到节点的left()
或right()
方法:
这里存在四个指针,怎么判断是否还有下一层呢,因为我们要检查节点冲突是根据两棵树的内侧节点进行比较,所以这里也只需要检查两个内侧节点指针来判断是否还有下一层,我们只需走到较浅的树即可停止,另一棵树更深的节点不会发生冲突,所以判断vInnerLeft.right()
和vInnerRight.left()
是否都存在即可。
2.下移一层后发现已经达到F
的叶子节点了,那么接下来就进行判断,重复一下我们的原则:
将线程从浅的树的外侧设置到深的树的内侧
浅的树为F
子树,深的树为C
子树,那么从F
的外侧设置到C
的内侧,也就是要将E
节点和A
节点通过线程连接起来。
具体的判断规则为:
2.1.如果
vInnerLeft.right()
节点(也就是B
节点所在树的右侧轮廓的下一个节点,这里是存在的,为A
节点)存在,且vOuterRight.right()
节点(也就是E
节点所在树的右侧轮廓的下一个节点,这里是不存在的)不存在,那么就在vOuterRight
节点上设置线程thread
属性指向vInnerLeft.right()
节点,这里刚好满足这个条件,所以E.thread
指向了A
节点。
2.2.否则如果
vOuterLeft.left()
节点(也就是B
节点所在树的左轮廓的下一个节点,这里是存在的,为A
节点)不存在,且vInnerRight.left()
节点(也就是D
节点所在树的左轮廓的下一个节点,这里是不存在的)存在,那么就在vOuterLeft
节点上设置线程thread
属性指向vInnerRight.left()
节点,显然这里不满足条件。
对于其他所有节点,都用这种方法判断,最终这棵树上线程节点连接为:
因为我们是后序遍历树,所以越下层的节点线程连接的越早,比如处理O
节点时候就会把I
和J
节点连接起来了,那么在后面处理P
节点时,虽然也走到了I
节点,但是I
节点因为有了线程节点,所以一定程度上它就不是“叶子节点”了,所以I
不会再被连接到其他节点上。
// 第一次递归 const firstwalk = (v, distance = 1) => { if (v.children.length === 0) { // ... } else { v.children.forEach((child) => { firstwalk(child); apportion(child);// ++ }); // ... } // ... } const apportion = (v) => { let leftBrother = v.left_brother(); // 存在左兄弟才处理 if (leftBrother) { // 四个节点指针 let vInnerRight = v;// 右子树左轮廓 let vOuterRight = v;// 右子树右轮廓 let vInnerLeft = leftBrother;// 当前节点的左兄弟节点,左子树右轮廓 let vOuterLeft = v.leftmost_sibling;// 当前节点的最左侧的兄弟节点,左子树左轮廓 // 一直遍历到叶子节点 while(vInnerLeft.right() && vInnerRight.left()) { // 更新指针 vInnerLeft = vInnerLeft.right() vInnerRight = vInnerRight.left() vOuterLeft = vOuterLeft.left() vOuterRight = vOuterRight.right() } // 将线程从浅的树的外侧设置到深的树的内侧 if (vInnerLeft.right() && !vOuterRight.right()) { vOuterRight.thread = vInnerLeft.right(); } else { if (vInnerRight.left() && !vOuterLeft.left()) { vOuterLeft.thread = vInnerRight.left(); } } } };
线程节点连接好了,接下来就可以根据轮廓判断两棵树是否存在交叉,同样因为我们是后序遍历,所以判断某个子树是否存在冲突时它下面的节点线程肯定已经连接完成了,可以直接使用。
根据轮廓判断的逻辑同样也放在apportion
方法里:
// 第一次递归 const firstwalk = (v, distance = 1) => { if (v.children.length === 0) { // ... } else { v.children.forEach((child) => { firstwalk(child); apportion(child, distance);// distance++ }); // ... } // ... } const apportion = (v, distance) => { let leftBrother = v.left_brother(); if (leftBrother) { // ... // 从当前节点依次往下走,判断是否和左侧的子树发生冲突 while(vInnerLeft.right() && vInnerRight.left()) { // ... // 左侧节点减右侧节点 let shift = vInnerLeft.x + distance - vInnerRight.x if (shift > 0) { // 大于0说明存在交叉,那么右侧的树要向右移动 move_subtree(v, shift) } } // ... } } // 移动子树 const move_subtree = (v, shift) => { v.x += shift// 自身移动 v.mod += shift// 后代节点移动 }
以节点P
为例,过程如下:
vInnerLeft.right()
存在(H.right()=F
),vInnerRight.left()
存在(P.left()=I
),所以下移一层:
比较F
和I
节点的x
坐标之差可以发现它们不存在冲突,于是继续下一层:
这一次比较会发现E
和J
节点发生冲突,那么子树P
需要整体向右移动一定距离。
当然,上述代码是有问题的,因为一个节点真正的最终x
坐标是还要加上它所有祖宗节点的mod
值,所以我们新增四个变量来累加mod
值:
const apportion = (v, distance) => { if (leftBrother) { // 四个节点指针 // ... // 累加mod值,它们的父节点是同一个,所以往上它们要加的mod值也是一样的,那么在后面shift值计算时vInnerLeft.x + 父节点.mod - (vInnerRight.x + 父节点.mod),父节点.mod可以直接消掉,所以不加上面的祖先节点的mod也没关系 let sInnerRight = vInnerRight.mod; let sOuterRight = vOuterRight.mod; let sInnerLeft = vInnerLeft.mod; let sOuterLeft = vOuterLeft.mod; // 从当前节点依次往下走,判断是否和左侧的子树发生冲突 while (vInnerLeft.right() && vInnerRight.left()) { // ... // 左侧节点减右侧节点,需要累加上mod值 let shift = vInnerLeft.x + sInnerLeft + distance - (vInnerRight.x + sInnerRight); if (shift > 0) { // ... // v.mod,也就是节点P.mod增加了shift,sInnerRight、sOuterRight当然也要同步增加 sInnerRight += shift; sOuterRight += shift; } // 累加当前层节点mod sInnerRight += vInnerRight.mod; sOuterRight += vOuterRight.mod; sInnerLeft += vInnerLeft.mod; sOuterLeft += vOuterLeft.mod; } // ... } };
效果如下:
但是这样依然是有问题的,为啥呢,比如对于节点E
来说,它累加上了节点F
、H
的mod
值,但问题是H
节点并不是E
节点的祖先,它们只是通过一根线程虚线产生了连接而已,实际要加上的应该是节点F
、G
的mod
值才对,这咋办呢,还是以例子来看,我们假设部分节点的mod
值如下:
那么对于节点A
真正要累加的mod
值应该为:
B.mod + C.mod + G.mod = 1 + 2 + 3 = 6
但是因为线程连接,实际累加的mod
值变成了:
E.mod + F.mod + H.mod = 0 + 4 + 0 = 4
少了2
,如果能在线程节点E
和H
上设置一个特殊的mod
值上,来弥补上这相差的值岂不美哉,反正因为它们两个下面也没有子节点了,所以无论给它们设置什么mod
值都不会有影响。那么这个特殊的mod
值又怎么计算呢?
很简单,比如在第一次处理F
节点时,它存在左节点C
,所以进行它们下面的节点的线程连接判断,因为它们都存在子级,所以下移一层,此时F
子树到头了,C
子树没有,此时满足vInnerLeft.right() && !vOuterRight.right()
的条件,会把E
连接到A
,对于C
和F
来说,它们的祖先节点都是一样的,所以祖先节点的mod
值不用管,那么对于A
节点来说,它真正要累加的mod
值为B.mod + C.mod
,而根据线程连接它会加上的mod
值为E.mod + F.mod
,两个式子的运算结果要相同,那么求E.mod
显然等于B.mod + C.mod - F.mod
,也就是sInnerLeft - sOuterRight
,修改代码如下:
// 将线程从浅的树的外侧设置到深的树的内侧 if (vInnerLeft.right() && !vOuterRight.right()) { vOuterRight.thread = vInnerLeft.right(); // 修正因为线程影响导致mod累加出错的问题,深的树减浅的树 vOuterRight.mod += sInnerLeft - sOuterRight// ++ } else { if (vInnerRight.left() && !vOuterLeft.left()) { vOuterLeft.thread = vInnerRight.left(); vOuterLeft.mod += sInnerRight - sOuterLeft// ++ } }
此时效果如下:
到这里冲突是没有了,但是H
的位置应该居中才对,显然是要向右移动,移动多少呢,子树P
向右移动了shift
距离,那么这个距离需要平分到G
、H
、P
三个节点之间的间距上,假设两个冲突子树之间的子树数量为n
,那么就是shift / (n + 1)
,子树H
向右移动这个距离即可。
换言之,我们先要找到是哪两棵子树发生了冲突,才能修正它们之间的树,上图可以看到发生冲突的是E
和J
节点,对于J
节点,我们肯定知道它属于当前的顶层子树P
,那么只要能找出E
节点所属的树即可,我们可以一眼就看出来是G
节点,但是代码没有眼,可以直接通过向上递归来找,但是为了线性时间复杂度我们也不能这么做。
我们给每个节点都设置一个ancestor
属性,初始都指向自身,对于E
节点,虽然在冲突判断时它属于vInnerLeft
节点,但是在它所属的树上,它属于vOuterRight
节点,所以在线程连接阶段,我们可以顺便设置一下每层的vOuterRight
节点的ancestor
,让它指向当前的顶层节点v
,但是这个指向有时不一定满足我们的要求,比如上图的N
节点,它的ancestor
成功的指向了P
节点,但是对于E
节点来说,它的ancestor
指向的是它的父节点F
,而我们需要的是G
,所以我们再设置一个变量default_ancestor
,当一个节点的ancestor
指向不满足我们的要求时就使用default_ancestor
指向的节点,default_ancestor
初始指向一个节点的第一个子节点,然后从每个子节点回来时都更新该指针,如果前一个子节点没有后一个子节点深,那么default_ancestor
就更新为指向后一个子节点,因为如果右侧有子树和左侧发生冲突,那么一定是和较深的那一棵。
const firstwalk = (v, distance = 1) => { if (v.children.length === 0) { // ... } else { let default_ancestor = v.children[0]// ++初始指向第一个子节点 v.children.forEach((child) => { firstwalk(child); default_ancestor = apportion(child, distance, default_ancestor);// 递归完每一个子节点都更新default_ancestor }); } } const apportion = (v, distance, default_ancestor) => { let leftBrother = v.left_brother(); if (leftBrother) { // ... while (vInnerLeft.right() && vInnerRight.left()) { // ... // 节点v下面的每一层右轮廓节点都关联v vOuterRight.ancestor = v;// ++ // ... } // ... if (vInnerLeft.right() && !vOuterRight.right()) { // ... } else { // ... default_ancestor = v// ++,前面的节点没有当前节点深,那么default_ancestor指向当前节点 } } return default_ancestor;// ++ }
然后我们就可以找出左侧树发生冲突的节点所属的根节点:
const apportion = (v, distance, default_ancestor) => { let leftBrother = v.left_brother(); if (leftBrother) { // ... while (vInnerLeft.right() && vInnerRight.left()) { // ... let shift = vInnerLeft.x + sInnerLeft + distance - (vInnerRight.x + sInnerRight); if (shift > 0) { // 找出vInnerLeft节点所属的根节点 let _ancestor = ancestor(vInnerLeft, v, default_ancestor)// ++ move_subtree(v, shift); // ... } // ... } // ... } return default_ancestor;// ++ } // 找出节点所属的根节点 const ancestor = (vInnerLeft, v, default_ancestor) => { // 如果vInnerLeft节点的ancestor指向的节点是v节点的兄弟,那么符合要求 if (v.parent.children.includes(vInnerLeft.ancestor)) { return vInnerLeft.ancestor; } else { // 否则使用default_ancestor指向的节点 return default_ancestor } }
找出了是哪两棵树发生冲突后我们就能找到这两棵树之间的子树,然后把shift
分摊给它们即可,不过我们还是不能直接遍历它们进行修正,没错,还是为了保持线性时间复杂度,所以只能先把分摊数据保存到这两棵冲突的树根节点上,然后等它们的所有兄弟节点都递归完成了再一次性设置。
const firstwalk = (v, distance = 1) => { if (v.children.length === 0) { // ... } else { let default_ancestor = v.children[0] v.children.forEach((child) => { firstwalk(child); default_ancestor = apportion(child, distance, default_ancestor); }); // 将shift分摊添加到中间节点的x及mod值上 execute_shifts(v)// ++ // ... } } const apportion = (v, distance, default_ancestor) => { let leftBrother = v.left_brother(); if (leftBrother) { // ... while (vInnerLeft.right() && vInnerRight.left()) { // ... if (shift > 0) { let _ancestor = ancestor(vInnerLeft, v, default_ancestor) move_subtree(_ancestor, v, shift);// ++ // ... } // ... } // ... } return default_ancestor;// ++ } const execute_shifts = (v) => { let change = 0 let shift = 0 // 从后往前遍历子节点 for(let i = v.children.length - 1; i >= 0; i--) { let node = v.children[i] node.x += shift node.mod += shift change += node.change// change一般为负值 shift += node.shift + change// 越往左,节点添加的shift值越小 } } const move_subtree = (leftV, v, shift) => { let subTrees = v.number - leftV.number// 索引相减,得到节点之间被分隔的数量 let average = shift / subTrees// 平分偏移量 v.shift += shift// 完整的shift值添加到v节点的shift属性上 v.change -= average// v左边的节点从右往左要添加的偏移量是递减的,所以是加上负的average leftV.change += average// v.change减了average,为了不影响leftV左侧的节点,这里需要恢复 // ... };
接下来以下图为例来看一下这个过程,假设P
节点最终计算出来的shift = 3
,那么P.number - G.number = 4 - 1 = 3
,中间节点分摊的值3 / 3 = 1
,节点G
到P
之间的节点要距离相等的话,H
需要向右移动1
,H2
要移动1 + 1
,这样它们的坐标为1,3,5,7
,等差数列,间距相等,如果还有更多节点,以此类推,因为越右边的节点移动了本身的1
后,还被前面的n
个节点向右推了n * 1
,我们把这两个值保存到节点G
和P
上:
然后执行execute_shifts
方法从后往前遍历Q
的子节点:
1.
change=0
,shift=0
,首先更新最后一个节点P2
:P2.x
和P2.mod
加上shift
,即加0
,更新change
:change + P2.change = 0 + 0 = 0
,更新shift
:shift + P2.shift + change = 0 + 0 + 0 = 0
2.更新
P
节点:P.x
和P.mod
加上shift
,即加0
,更新change
:change + P.change = 0 + (-1) = -1
,更新shift
:shift + P.shift + change = 0 + 3 + (-1) = 2
3.更新
H2
节点:H2.x
和H2.mod
加上shift
,即加2
,更新change
:change + H2.change = -1 + 0 = -1
,更新shift
:shift + H2.shift + change = 2 + 0 + (-1) = 1
4.更新
H
节点:H.x
和H.mod
加上shift
,即加1
,更新change
:change + H.change = -1 + 0 = -1
,更新shift
:shift + H.shift + change = 1 + 0 + (-1) = 0
5.更新
G
节点:G.x
和G.mod
加上shift
,即加0
,更新change
:change + G.change = -1 + 1 = 0
,更新shift
:shift + G.shift + change = 0 + 0 + 0 = 0
6.更新
G0
节点:G0.x
和G0.mod
加上shift
,即加0
,更新change
:change + G0.change = 0 + 0 = 0
,更新shift
:shift + G0.shift + change = 0 + 0 + 0 = 0
以上就是译者马后炮式的理解,最终效果:
x
和y
交换一下:
实现思维导图
上述算法还是不能直接应用于思维导图的,因为前面考虑的树每个节点的大小都是一样的,而思维导图每个节点的宽高都是有可能不同的,需要在上述算法的基础上进行一定修改。
我们把算法的x
和y
交换一下,让树水平排布,然后修改一下节点的形状和连线方式(这不是本文的重点,有兴趣可以去文末的仓库里查看源码),然后看看当前算法对于宽高不同的节点来说效果是怎么样的:
可以看到I
节点不仅和同级的O
节点发生冲突,还和跨子树的E2
节点发生冲突,我们要的效果是每棵子树整体都不和其他子树交叉,先看看最终我们的效果:
先处理一下比较简单的水平x
坐标,之前的算法没有考虑节点本身的宽度,所以对于每个节点来说我们只需要给它的x
坐标累加上它所有祖宗节点的宽度,修改第二次遍历second_walk
:
const second_walk = (v, m = 0, depth = 0, s = 0) => { v.y += m; v.x = depth * distance + s; v.children.forEach((child) => { second_walk(child, m + v.mod, depth + 1, s + v.width); }); };
接下来我们要想办法让每棵子树都不和其他子树有交叉,即使是像下面这样也不行:
不同的子树水平方向不能有重叠。笔者的思路是计算出每棵子树的最小y
值和最大y
值,比如子树G
,它的最小y
和最大y
值如下:
计算出一棵子树的最y
值之后如果它存在左兄弟
(水平方向后对应的是上兄弟),那么就和左兄弟
进行比较,具体来说就是判断该节点的最小y值
是否小于左兄弟
的最大y值
,当然还需要考虑间距,如果存在交叉,那么该节点就向下移动这个交叉值,为了保持O(n)
时间复杂度,我们和前面的思路一样,把这个差值也保存到一个变量上,在下一次遍历时再累加到子节点上。
先给子节点类添加几个属性:
class DrawTree { constructor(tree, parent = null, depth = 0, number = 1) { // ... this.minY = 0 this.maxY = 0 this.offset = 0 } }
然后开启第三次遍历third_walk
:
const third_walk = (tree) => { // 节点自身的最大y值和最小y值 let selfMinY = tree.y - tree.height / 2 let selfMaxY = tree.y + tree.height / 2 // 计算每个节点的minY和maxY if (tree.children.length > 0) { let minY = Infinity let maxY = -Infinity // 找出所有后代节点的最y值之最 tree.children.forEach((child) => { third_walk(child); if (child.minY < minY) { minY = child.minY } if (child.maxY > maxY) { maxY = child.maxY } }); // 设置当前节点的最y值 tree.minY = selfMinY < minY ? selfMinY : minY tree.maxY = selfMaxY > maxY ? selfMaxY : maxY } else { // 没有后代节点,那么最y值就是自身的最y值 tree.minY = selfMinY tree.maxY = selfMaxY } // 判断是否和左兄弟有交叉 if (tree.left_brother()) { if (tree.minY < tree.left_brother().maxY + distance) { let o = tree.left_brother().maxY - tree.minY + distance tree.offset = o// 用于移动后代节点 tree.y += o// 移动自身 tree.minY += o// 更新minY、maxY tree.maxY += o } } };
第三次遍历后效果是这样的:
因为我们还没有把差值添加到后代节点上,接下来让我们开启第四次遍历fourth_walk
:
const fourth_walk = (tree, o = 0) => { tree.y += o tree.children.forEach((child) => { fourth_walk(child, o + tree.offset); }); };
可以发现父节点又不居中于子节点了,很简单,后代节点累加完offset
后我们再计算一次子节点的中间位置并设置到父节点上,修改第四次遍历:
const fourth_walk = (tree, o = 0) => { tree.y += o tree.children.forEach((child) => { fourth_walk(child, o + tree.offset); }); let len = tree.children.length if (len <= 0) { return } // 重新居于子节点中间 let mid = (tree.children[0].y + tree.children[len - 1].y) / 2 tree.y = mid };
y
方向节点间距太大,减小一点,最终效果如下:
以上就是笔者的个人思路,额外又添加了两次遍历,可能有更好的方式,欢迎留言探讨。
在线示例wanglin2.github.io/tree_layout…,
参考链接
2.树型界面绘制算法(二)简单明了的First-Second
5.A Node-positioning Algorithm for General Trees