「学习记录」前端可视化:一种紧凑树形布局算法的实现(简易版)

2,263 阅读7分钟

前提

前段时间做了个与流程图相关的需求,这需求中涉及到了一个自动调整布局的功能

这让我这个算法小白着实是想破的头脑

但是总算让我找到了一种布局算法,可用于调整流程图等可视化节点图的布局,让页面空间利用率更高

这就是今天要说的 紧凑树形布局算法

目标

  1. 熟悉该算法的简易实现

文章参考以及示例代码

这里要感谢两位大佬的代码,让我受益匪浅

后面的内容都是参考大佬们的代码后的一种简易实现

如果想更深入了解可以去大佬们的github clone下来慢慢研究

一种紧凑树形布局算法的实现

基于vue和jsplumb的工作流编辑器开发(二)

我的演示代码

前端可视化之:一种紧凑树形布局算法的实现(简易版)

预期效果

下面是我们想要做到的预期效果,由于本文只研究节点布局,不涉及连线,所以在此忽略了连线的步骤。

我们想要做到的效果如下,相邻节点之间有着最小间距,不会重叠。

所有分支节点都基于父节点居中。

这样的布局首先就是美观,而且空间利用率高。

image.png

模拟tree数据

首先我们来mock一下我们需要的tree数据

通常后端给到我们的是一个一维对象数组

这里只有一些简单的name,id,parent,children,

父子节点关系参考上面的图片

export const treeInitData = [
	{
		name: 'node-10',
		id: 10,
		parent: '',
		children: [1]
	},
	{
		name: 'node-1',
		id: 1,
		parent: 10,
		children: [2, 3, 4]
	},
	{
		name: 'node-2',
		id: 2,
		parent: 1,
		children: [5, 6, 8]
	},
	{
		name: 'node-3',
		id: 3,
		parent: 1,
		children: [7, 9]
	},
	{
		name: 'node-4',
		id: 4,
		parent: 1,
		children: []
	},
	{
		name: 'node-5',
		id: 5,
		parent: 2,
		children: []
	},
	{
		name: 'node-6',
		id: 6,
		parent: 2,
		children: []
	},
	{
		name: 'node-7',
		id: 7,
		parent: 3,
		children: []
	},
	{
		name: 'node-8',
		id: 8,
		parent: 2,
		children: []
	},
	{
		name: 'node-9',
		id: 9,
		parent: 3,
		children: []
	}
];

初始化一些页面常量

接下来,我们创建一个html文件,引入我们的vue包,利用vue的模板渲染进行布局,当然你用react也是一样的,任何渲染器都可以,这里只讨论算法实现

先展示一些样式信息

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>一种紧凑树形布局算法的实现(简易版)</title>
    <style>
        html,
        body {
            margin: 0;
            padding: 0;
            border: 0;
        }

        #treeBox {
            width: 100vw;
            height: 100vh;
            position: relative;
            background-color: #eeeeee;
        }

        .treeNode {
            position: absolute;
            background-color: #ffffff;
            text-align: center;
            line-height: 40px;
        }
    </style>
</head>
    

</html>

接着是模板语法部分

<body>
    <div id="app">
        <div id="treeBox">
            <div class="treeNode" v-for="node in layoutTree" :key="node.id" :style="getStyle(node)">
                {{node.id}}
            </div>
        </div>
    </div>
</body>

接着是vue的状态部分data,函数部分

<script src="https://ability-1251624226.file.myqcloud.com/base/vue.min.js"></script>
    <script type="module">
        import { treeInitData } from "./treeInitData2.js"
        const NODE_SIZE = 40
        const Y_INTERVAL = 100
        const X_INTERVAL = 150
        const BOX_WIDTH = document.getElementById('treeBox').offsetWidth
        const MID_WIDTH = BOX_WIDTH / 2

        new Vue({
            data() {
                return {
                    treeInitData,
                    treeData: null,
                    layoutTree: [],
                    hashTree: [],
                    rootNode: null
                }
            },
            mounted() {
                
            },
            methods: {
                getStyle(node) {
                    return {
                        left: node.left + 'px', top: node.top + 'px',
                        width: NODE_SIZE + 'px',
                        height: NODE_SIZE + 'px'
                    }
                },
            },

        }).$mount('#app')
    </script>

这里重点讲一下几个常量的意思:

  1. NODE_SIZE :节点宽高
  2. Y_INTERVAL :纵向间距
  3. X_INTERVAL :横向最小间距
  4. BOX_WIDTH : 容器盒子宽度
  5. MID_WIDTH : 容器盒子宽度的一半(中线)

这几个常量在实际开发中可以根据不同分辨率来调整

构造布局树,根节点,hashTree

我们在上面的data中看到layoutTree,hashTree,rootNode三个响应式数据

  1. layoutTree是我们用来在模板语法中布局用的一维数组,存在left和top两个字段
  2. hashTree是一个二维数组,记录了每一层的节点
  3. rootNode是根节点,是我们进行树的遍历的入口

接下来我们就来构建这三个东西:

 <script type="module">
        import { treeInitData } from "./treeInitData2.js"
        const NODE_SIZE = 40
        const Y_INTERVAL = 100
        const X_INTERVAL = 150
        const BOX_WIDTH = document.getElementById('treeBox').offsetWidth
        const MID_WIDTH = BOX_WIDTH / 2

        new Vue({
            data() {
                return {
                    treeInitData,
                    treeData: null,
                    layoutTree: [],
                    hashTree: [],
                    rootNode: null
                }
            },
            mounted() {
                const { layoutTree, rootNode, hashTree } = this.Array2Tree(this.treeInitData)
                this.layoutTree = layoutTree
                this.rootNode = rootNode
                this.hashTree = hashTree
            },
            methods: {
                
                getStyle(node) {
                    return {
                        left: node.left + 'px', top: node.top + 'px',
                        width: NODE_SIZE + 'px',
                        height: NODE_SIZE + 'px'
                    }
                },
                Array2Tree(arr) {
                    // 创建布局树
                    let layoutTree = arr.map(item => {
                        return {
                            ...item,
                            left: 0,
                            top: 0
                        }
                    })
                    // 创建树结构并找到根节点
                    let treeMap = {}
                    layoutTree.forEach(element => {
                        treeMap[element.id] = element
                    });
                    layoutTree.forEach(item => {
                        if (item.children) {
                            item.children = item.children.map(id => treeMap[id])
                        }
                    })
                    let rootNode = layoutTree.find(item => !item.parent)
                    // 创建哈希树,获取层级关系
                    let hashTree = []
                    // hashTree[zindex] = [hashTree]
                    const getHashTree = (root, hashTree, zindex) => {
                        if (!root) {
                            return
                        }
                        if (hashTree[zindex]) {
                            hashTree[zindex].push(root)
                        } else {
                            hashTree[zindex] = [root]
                        }
                        root.children.forEach(node => {
                            getHashTree(node, hashTree, zindex + 1)
                        })
                    }
                    getHashTree(rootNode, hashTree, 0)
                    console.log(layoutTree, rootNode, hashTree)
                    return { layoutTree, rootNode, hashTree }
                }
            },

        }).$mount('#app')
    </script>

在上面的代码中,我们用数组方法map去构造layoutTree,给它加上了left: 0,top: 0。我们给节点初始坐标为(0,0),

接着,我们找到rootNode,并进一步改造layoutTree,通过foreach把children改造成对象数组,

接着,我们利用递归调用getHashTree,创建一个hashTree,这里递归的用法比较简单,不做解析。

有了这三个数据,我们可以开始布局

开始布局

布局整体上分为三个步骤:

  1. Y轴纵向布局

  2. X轴横向布局

  3. X轴子节点调整

我们算法的核心是最后一个,X轴子节点调整。

Y轴纵向布局

这个布局比较简单,由于我们以及拥有了表示层级关系的hashTree,

很轻松的就可以完成Y轴方向上的设置

// 布局Y
                Layout_Y() {
                    for (let i = 0; i < this.hashTree.length; i++) {
                        for (let j = 0; j < this.hashTree[i].length; j++) {
                            this.hashTree[i][j].top = Y_INTERVAL * i + NODE_SIZE * i
                        }
                    }
                },

接着我们在钩子中调用:

mounted() {
                const { layoutTree, rootNode, hashTree } = this.Array2Tree(this.treeInitData)
                this.layoutTree = layoutTree
                this.rootNode = rootNode
                this.hashTree = hashTree
                // 纵向布局
                this.Layout_Y()
            },

我们来看看效果

image.png

可以看到Y轴方向上的设置已经完成,后续布局也不需要再进行变化

X轴横向布局

这一步我们要做到的效果是根据设定的X最小横向距离去进行布局,

这就有些难度了,可能不同人会有不一样的写法,这里我选用的是递归的方式,进行树的深度优先遍历

先贴代码

Layout_X() {
                    this.layout_X_deep(this.rootNode, MID_WIDTH)
                },
                layout_X_deep(node, leftVal) {
                    if (!node) return;
                    // 距离中线居中显示
                    node.left = leftVal - NODE_SIZE / 2
                    // 子节点宽度
                    let lineWidth = (node.children.length - 1) * X_INTERVAL + node.children.length * NODE_SIZE
                    let baseLeft = leftVal - lineWidth / 2
                    // 遍历子节点
                    for (let i = 0; i < node.children.length; i++) {
                        this.layout_X_deep(node.children[i], baseLeft + NODE_SIZE / 2 + i * (NODE_SIZE + X_INTERVAL))
                    }
                },

同样的我们在钩子函数里调用一下

mounted() {
                const { layoutTree, rootNode, hashTree } = this.Array2Tree(this.treeInitData)
                this.layoutTree = layoutTree
                this.rootNode = rootNode
                this.hashTree = hashTree
                // 纵向布局
                this.Layout_Y()
    			// 初始化横向布局
                this.Layout_X()
            },

我们来解读一些关键代码:

image.png

红框1代码:调用递归函数,传入根节点,与容器中线,注意后面传入的参数也是该节点中线坐标。

红框2代码:设置节点横坐标,由于考虑节点宽高,需要中线坐标减去减去节点坐标的一半

红框3代码:求得所有子节点的宽度,需要考虑节点本身的宽度,即:节点数量x节点宽度+(节点数量-1)x最小节点纵向间距,最后用传入的leftval减去这个值的一半,就是子节点的起始坐标

红框4代码:遍历子节点,传入子节点的中线坐标

最后的效果如下:

image.png

我们可以看到,在节点2与节点3的子节点中,有重叠的部分,接下来算法的核心就是处理这样的情况。

X轴子节点调整

这一块是整个布局算法的核心,我们会再次用带hashTree这个数据结构,再次献上上面的图片

image.png

我们先甩一个调整步骤,接下来再用代码去实现

步骤1: 倒序遍历hashTree,从最后一层开始,两两节点对比left值,存在重叠或者小于最小间距的情况,找到其共同祖先节点下的同层子节点(比如上面的节点2,与节点3)

步骤2:根据重叠节点的差值与最小横向间距,算出整个节点3以及子树需要向右偏移多少才能消除节点重叠的情况,即偏移量leftOffset,然后整体把子树3向右偏移leftOffset。

步骤3:由于已经进行了子树偏移,需要重新居中一下整个布局,这时候我们找到刚刚节点3的父节点1,根据子节点的数量区分单分支与多分支的情况,算出需要偏移的量,再次对节点1所有的子树进行移动

步骤4:由于已经进行了子树偏移,我们需要又返回最后一层重新进行遍历比较,直到布局完毕

所以整个布局流程主要分为以上3步

代码如下:

遍历hashTree的代码

在这里主要是对hashTree进行遍历,然后进行同层俩俩节点判断,在这里leftoffset的计算公示为(最小横向间距+节点宽度-后前俩节点left值的差),然后进行子树移动,再进行居中调整,最后把i设置为最后一层重新遍历。

image.png

节点与子树偏移的代码

这里运用了递归

image.png

居中调整的代码

这里主要是针对单子节点与多子节点的情况算出leftoffset,再整体移动子树

这里关注一下红框内的计算代码

  1. (nextFlowItemLatest.left - nextFlowItemFirst.left) / 2 算出子节点整体长度
  2. (nextFlowItemFirst.left + (nextFlowItemLatest.left - nextFlowItemFirst.left) / 2) 算出当前子节点的中线
  3. flowItem.left - (nextFlowItemFirst.left + (nextFlowItemLatest.left - nextFlowItemFirst.left) / 2) 与父节点的中线相减就是需要移动的位移

image.png

总结下来就是:找到重叠,移动子树,居中调整,再次遍历hashtree

运用场景

该布局的可运用于含树型结构的可视化展示中

比如一些自动排列功能

可以很有效的提高空间利用率

希望大家如果遇到了类似的需求情况

也可以用上这个算法