图解树布局算法,轻松实现思维导图

笔者不久前翻译了一篇介绍树布局算法的文章【译】绘制一棵漂亮的树,但是那篇文章对于算法只是大致介绍了实现的思路,属于启发式文章,虽然有完整的代码,但是要理解起来还是有一定难度,并且要基于该算法实现思维导图也需要进行一定修改,所以本文会通过图解的方式一步步的分解该算法,并最终实现一个思维导图布局。

阅读本文前推荐先阅读一下译文,方便理解文中提到的一些概念。

算法图解

节点类如下,请务必仔细看一下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;
};
复制代码

第一次递归后的节点位置:

image-20220318102931215.png

第二次递归后的节点位置:

image-20220318102949466.png

明显存在冲突的子树可以看到是GP两棵子树,子树P需要向右移动一定的距离才行,这个距离怎么算呢?

1.进入子树GP的第二层,找到子树G在这一层中的最右侧子节点,为F,找到子树P在这一层的最左侧子节点,为I,比较它们的x坐标,原始x值加上它们祖先节点的mod值之和,比较后发现没有交叉,于是进入下一层。

2.进入第三层,同样找到子树G在这一层中的最右侧子节点,为E,子树P在这一层的最左侧子节点,为J,比较它们的x,发现存在交叉,这个差值再加上节点的间隔distance就是子树P需要向右移动的距离

3.重复以上,直到最底层。

那么怎么最快速的找到每一层的最左侧或最右侧节点呢,当然可以直接递归,但是时间复杂度就非线性了,所以就引入了一个“线程”的概念(详细了解请阅读译文)。

以上图中的G节点为例介绍线程的连接过程,从它的子节点C回来后因为C没有左兄弟,所以不处理它,进入F节点递归,从F节点回来之后紧接着处理F节点,它存在左兄弟C,因为每棵树都有内侧和外侧,所以我们设置四个指针:

image-20220318104203798.png

vInnerLeft为当前节点的左兄弟节点,vOuterLeft为当前节点的最左侧的兄弟节点,当然对于F节点来说,这两个指针都指向C节点,vInnerRightvOuterRight初始都指向当前节点。

接下来我们就将线程从浅的树的外侧设置到深的树的内侧

1.因为CF节点都存在子节点,所以这一层还无法判断哪棵树深哪棵树浅,所以就下移一层,同时更新四个指针,这里就会用到节点的left()right()方法:

image-20220318105921843.png

这里存在四个指针,怎么判断是否还有下一层呢,因为我们要检查节点冲突是根据两棵树的内侧节点进行比较,所以这里也只需要检查两个内侧节点指针来判断是否还有下一层,我们只需走到较浅的树即可停止,另一棵树更深的节点不会发生冲突,所以判断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()节点,显然这里不满足条件。

对于其他所有节点,都用这种方法判断,最终这棵树上线程节点连接为:

image-20220318112225285.png

因为我们是后序遍历树,所以越下层的节点线程连接的越早,比如处理O节点时候就会把IJ节点连接起来了,那么在后面处理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为例,过程如下:

image-20220318154717319.png

vInnerLeft.right()存在(H.right()=F),vInnerRight.left()存在(P.left()=I),所以下移一层:

image-20220318154901212.png

比较FI节点的x坐标之差可以发现它们不存在冲突,于是继续下一层:

image-20220318155104532.png

这一次比较会发现EJ节点发生冲突,那么子树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;
        }
        // ...
    }
};
复制代码

效果如下:

image-20220318155814623.png

但是这样依然是有问题的,为啥呢,比如对于节点E来说,它累加上了节点FHmod值,但问题是H节点并不是E节点的祖先,它们只是通过一根线程虚线产生了连接而已,实际要加上的应该是节点FGmod值才对,这咋办呢,还是以例子来看,我们假设部分节点的mod值如下:

image.png

那么对于节点A真正要累加的mod值应该为:

B.mod + C.mod + G.mod = 1 + 2 + 3 = 6
复制代码

但是因为线程连接,实际累加的mod值变成了:

E.mod + F.mod + H.mod = 0 + 4 + 0 = 4
复制代码

少了2,如果能在线程节点EH上设置一个特殊的mod值上,来弥补上这相差的值岂不美哉,反正因为它们两个下面也没有子节点了,所以无论给它们设置什么mod值都不会有影响。那么这个特殊的mod值又怎么计算呢?很简单,比如在第一次处理F节点时,它存在左节点C,所以进行它们下面的节点的线程连接判断,因为它们都存在子级,所以下移一层,此时F子树到头了,C子树没有,此时满足vInnerLeft.right() && !vOuterRight.right()的条件,会把E连接到A,对于CF来说,它们的祖先节点都是一样的,所以祖先节点的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// ++
    }
}
复制代码

此时效果如下:

image.png

到这里冲突是没有了,但是H的位置应该居中才对,显然是要向右移动,移动多少呢,子树P向右移动了shift距离,那么这个距离需要平分到GHP三个节点之间的间距上,假设两个冲突子树之间的子树数量为n,那么就是shift / (n + 1),子树H向右移动这个距离即可。

换言之,我们先要找到是哪两棵子树发生了冲突,才能修正它们之间的树,上图可以看到发生冲突的是EJ节点,对于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,节点GP之间的节点要距离相等的话,H需要向右移动1H2要移动1 + 1,这样它们的坐标为1,3,5,7,等差数列,间距相等,如果还有更多节点,以此类推,因为越右边的节点移动了本身的1后,还被前面的n个节点向右推了n * 1,我们把这两个值保存到节点GP上:

image.png

然后执行execute_shifts方法从后往前遍历Q的子节点:

1.change=0shift=0,首先更新最后一个节点P2P2.xP2.mod加上shift,即加0,更新changechange + P2.change = 0 + 0 = 0,更新shiftshift + P2.shift + change = 0 + 0 + 0 = 0

2.更新P节点:P.xP.mod加上shift,即加0,更新changechange + P.change = 0 + (-1) = -1,更新shiftshift + P.shift + change = 0 + 3 + (-1) = 2

3.更新H2节点:H2.xH2.mod加上shift,即加2,更新changechange + H2.change = -1 + 0 = -1,更新shiftshift + H2.shift + change = 2 + 0 + (-1) = 1

4.更新H节点:H.xH.mod加上shift,即加1,更新changechange + H.change = -1 + 0 = -1,更新shiftshift + H.shift + change = 1 + 0 + (-1) = 0

5.更新G节点:G.xG.mod加上shift,即加0,更新changechange + G.change = -1 + 1 = 0,更新shiftshift + G.shift + change = 0 + 0 + 0 = 0

6.更新G0节点:G0.xG0.mod加上shift,即加0,更新changechange + G0.change = 0 + 0 = 0,更新shiftshift + G0.shift + change = 0 + 0 + 0 = 0

以上就是译者马后炮式的理解,最终效果:

image.png

xy交换一下:

image.png

实现思维导图

上述算法还是不能直接应用于思维导图的,因为前面考虑的树每个节点的大小都是一样的,而思维导图每个节点的宽高都是有可能不同的,需要在上述算法的基础上进行一定修改。

我们把算法的xy交换一下,让树水平排布,然后修改一下节点的形状和连线方式(这不是本文的重点,有兴趣可以去文末的仓库里查看源码),然后看看当前算法对于宽高不同的节点来说效果是怎么样的:

image.png

可以看到I节点不仅和同级的O节点发生冲突,还和跨子树的E2节点发生冲突,我们要的效果是每棵子树整体都不和其他子树交叉,先看看最终我们的效果:

image.png

先处理一下比较简单的水平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);
    });
};
复制代码

image.png

接下来我们要想办法让每棵子树都不和其他子树有交叉,即使是像下面这样也不行:

image.png

不同的子树水平方向不能有重叠。笔者的思路是计算出每棵子树的最小y值和最大y值,比如子树G,它的最小y和最大y值如下:

image.png

计算出一棵子树的最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
        }
    }
};
复制代码

第三次遍历后效果是这样的:

image.png

因为我们还没有把差值添加到后代节点上,接下来让我们开启第四次遍历fourth_walk

const fourth_walk = (tree, o = 0) => {
    tree.y += o
    tree.children.forEach((child) => {
        fourth_walk(child, o + tree.offset);
    });
};
复制代码

image.png

可以发现父节点又不居中于子节点了,很简单,后代节点累加完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方向节点间距太大,减小一点,最终效果如下:

image.png

以上就是笔者的个人思路,额外又添加了两次遍历,可能有更好的方式,欢迎留言探讨。

在线示例wanglin2.github.io/tree_layout…,完整代码在github.com/wanglin2/tr….

参考链接

1.原生javascript实现树布局算法

2.树型界面绘制算法(二)简单明了的First-Second

3.树型界面绘制算法(三) 磨人的apportion

4.树形界面绘制算法(小结)

5.A Node-positioning Algorithm for General Trees

猜你喜欢

转载自juejin.im/post/7085158485607841805