含代码仓库 - 使用antv/x6+vue简易画树 demo(2/n叉树,导入导出) -- 实现收缩、增删子节点(自定义)

1,125 阅读9分钟

效果展示

动态演示

antv_x6_画树_和另外_9_个页面.gif

全局界面

image.png

收起/展开子树(收缩节点)

image.png

image.png

导出图片如下:

image.png

代码仓库

gitee: draw_tree_with_antv_x6: 使用antv_x6绘制简单树的demo (gitee.com)

github: Damon-law/draw_tree_with_antv_x6: 使用antv_x6绘制简单树的demo (github.com)

vecel: vercel

实现功能预览

  1. 生成模拟树数组(模拟后端存储的树结构)
  2. 树生成算法 -- 根据树数组数据(模拟/后端存储)计算并生成andv/x6的树结构(树节点,边节点,收缩节点)
  3. 收缩节点 -- 点击后收缩子树
  4. 动态增删节点 -- 可以点击选中节点,新增子节点和删除节点后,更新视图
  5. 导出图片 -- 使用andv/x6插件导出树图片
  6. 小地图 -- antv/x6插件
  7. 全屏/退出全屏展示 -- 基于vueuseuseFullScreen

需求背景(可跳过)

在项目中遇上了一个根据数据绘制树结构并存储增删树节点,导出树结构图片的场景。在经过技术调研后,使用了andv/x6写了个demo。调研期间接触的有andv/g6(文档不友好,且维护不太好);调研期间还筛选了其他一些,可以参考下: d3jsvue3-tree-orgOrganizational Chart等;

实现画树的思路(最主要)

  1. 画树其实最主要是确定每个节点的位置,最主要的是父节点子节点的位置。
  2. 由下图也可知,父节点的位置其实与子节点及其子孙节点即子节点为顶点的子树有关有关,父节点形成的子树节点的宽度等于所有子节点的子树宽度相加
  3. 我们计算出所有节点的子树节点宽度后,便可以知道每个节点应该放在什么位置(节点位于当前子树的中间位置)。

image.png image.png

由此得出了画树的思路:

  1. 自底向上计算出每个节点所占的宽度大小(子树宽度subTreeWidth),再通过pId更新其父节点的子树宽度subTreeWidth, 直至获得整棵树所占的宽度。

image.png

  1. 计算出每个节点的子树大小后,再自顶向下由左到右 计算出x6节点的x,y坐标.

image.png

image.png

模拟的树节点存储接口

模拟数据库存储的简单树节点结构

// 树节点结构
export const mockData = [
    {
        layer: 1,   // 层级
        id: uuidv4(),   // id, -1 表示虚拟根节点
        index: 1,   // 索引值
        pId: -1,    // 父节点id, null表示无父节点
    },
]

模拟生成存储树节点的算法(可跳过)

随机 或 根据传入的 层级每个节点的子节点数量 来模拟生成后端存储一棵简单树的数据。

此处末尾引入了一个虚拟根节点是考虑到了可能存在多个根节点,即一个页面画多棵树的情况,这时候可以把多棵树看成虚拟根节点的子节点去进行绘制。

import { v4 as uuidv4 } from 'uuid'
export const mockData = [
    {
        layer: 1,   // 层级
        id: uuidv4(),   // id, -1 表示虚拟根节点
        index: 1,   // 索引值
        pId: -1,    // 父节点id, null表示无父节点
    },
]


/**
 * @Author: Damon Liu
 * @Date: 2024-09-10 09:55:48
 * @LastEditors: Damon Liu
 * @LastEditTime: 
 * @Description: 模拟后端生成树的算法
 * @param {*} layer              树的层数
 * @param {*} childrenCount      子节点数量
 */
export const getData = function (_layer, _childrenCount) {
    const res = []; // 最终数据
    const layers = _layer ? _layer : parseInt(parseInt(Math.random() * 1e3) % 6) + 1;    //  获取生成树的层级有多少, 有传入值取传入值,无则随机
    const bfs = [...mockData];
    let index = 1;
    while(bfs.length) {
        const currentNode = bfs.shift();    // 获取节点
        res.push(currentNode);
        // 如果当前节点的层级已到达最高的节点
        if(currentNode.layer >= layers) {
            continue;
        }
        // 没有则添加子节点
        else {
            const childrenCount = _childrenCount ? _childrenCount : parseInt(parseInt(Math.random() * 1e3) % 3) + 1;
            for(let i = 0; i < childrenCount; ++i) {
                const newNode = {
                    id: uuidv4(),
                    layer: currentNode.layer + 1,
                    index: ++index,
                    pId: currentNode.id,
                }
                
                bfs.push(newNode);
            }
        }
    }
    // 虚拟根节点
    res.unshift({
        layer: 0,
        id: -1,
        index: -1,
        pId: null,
    })
    return res;
}

存储树转化为antv/x6能渲染的节点和边数据 (重点)

文件路径

image.png

按照上面讲解的思路, 主要实现如下:

  1. 先根据层级由大到小排序,自底向上计算出每个节点代表的子树所占的宽度。顺便根据最大的层级数计算出每层层数罗马数字标识的节点位置。
  2. 赋予虚拟根节点一个初始的x,y坐标,再排序,自顶向下从左到右 根据每个节点 子树宽度父节点的x,y父节点子树已使用宽度 计算出每个节点的 x , y 坐标, 计算出当前节点的 x , y 坐标后,更新父节点的已使用子树宽度(useWidth),用于兄弟节点下x,y的计算。
  3. 在进行2步骤的同时,当一个节点的x, y坐标被计算出来后, 更新父节点的收缩节点(不存在时候创建,并创建父节点收缩节点之间的边),更新收缩节点子节点之间的边, 以及收缩节点的子节点数据(用于收缩)。

注意: 虚拟根节点 在此处仅用于计算使用, 在绘制时候不展示。 主要是考虑可能存在多棵树的情况时,使用虚拟根节点作为多棵树的根节点。

import { romanize } from '../../utils/roma';

/**
 * @Author: Damon Liu
 * @Date: 2024-09-10 10:14:25
 * @LastEditors: Damon Liu
 * @LastEditTime: 
 * @Description:  一个节点的位置与
 * 
 * @param {*} data           后端存储的树结构
 * @param {*} nodeWidth      节点的宽度
 * @param {*} nodeHeight     节点的高度
 * @param {*} layerGutter    层间距     层与层之间间隔的距离
 * @param {*} nodeGutter     节点间距    同级节点直接的距离
 */
export const dataToTreeData = (data = [], nodeWidth = 80, nodeHeight = 80, layerGutter = 250, nodeGutter = 100) => {
    const dataCopy = JSON.parse(JSON.stringify(data));      // 不影响旧数据,copy一份
    const x6Nodes = [];     // 转换后的X6节点数组
    const x6Edges = [];    // 转换后的x6边组合
    const idToX6Map = {}; // id 对应对象map

    const calcNodes = [];       // 用于计算节点下子树节点的宽高,x,y 数据

    const idToCalcNodesMap = {};       // 用于记录计算数据的 id 对应节点的map

    const collapsableNodes = {};        // 存在收缩展开子节点的功能, 用于记录收缩节点的数据

    dataCopy.forEach((node) => {
        const x6Node = {
            id: node.id,
            shape: 'treeNode',
            width: nodeWidth,
            height: nodeHeight,
            x: 0,
            y: 0,
            data: {
                ...node // 保留额外数据
            }
        }
        idToX6Map[node.id] = x6Node;
        x6Nodes.push(x6Node);

        const calcNode = {
            ...node,
            width: nodeWidth,
            height: nodeHeight,
            subTreeWidth: 0,
            subTreeHeight: nodeHeight,
            usedWidth: 0    // 已经使用了的宽度
        }

        calcNodes.push(calcNode)
        idToCalcNodesMap[node.id] = calcNode;

    })
    // 先排序,防止后端传回/生成的数据顺序有错
    dataCopy.sort((a, b) => {
        // 先按 layer 降序排序
        if (a.layer !== b.layer) {
            return b.layer - a.layer;
        }
        // 同一层内按 pid 升序排序
        return a.pId - b.pId;
    })
    // 记录节点是否已经计算
    // const visitedMap = {};
    // 先绘制 层数编号 元素
    if (dataCopy.length) {
        const maxLayer = dataCopy[0].layer;
        for (let i = maxLayer; i > 0; --i) {
            x6Nodes.push({
                shape: 'rect',
                x: -300,
                y: 200 + i * (layerGutter + nodeHeight),
                width: 100,
                height: 100,
                attrs: {
                    body: {
                        fill: 'none',
                        stroke: 'none'
                    },
                    label: {
                        text: romanize(i),
                        fill: '#666',
                        fontSize: 48
                    }
                }
            })
        }
    }

    // 开始计算 每个节点子树的宽度, 由底向上更新 
    dataCopy.forEach((node) => {
        // 已访问
        //visitedMap[node.id] = true;
        const pid = node.pId;   // 获取父节点的Id
        // 更新子树的宽度, 从 自身节点宽度, 子树大小 中取最大值 (可能没有子树的时候,自身宽度则是子树宽度)
        const maxSubtreeWidth = Math.max(idToCalcNodesMap[node.id].subTreeWidth, idToCalcNodesMap[node.id].width);
        idToCalcNodesMap[node.id].subTreeWidth = maxSubtreeWidth;
        // 存在父节点更新父节点数据
        if (pid) {
            const parentNode = idToCalcNodesMap[pid];
            // 当父节点的子树宽度为0,即是没有兄弟节点访问过,或者不存在兄弟节点
            if (parentNode.subTreeWidth === 0) {
                parentNode.subTreeWidth = parentNode.subTreeWidth + maxSubtreeWidth;    // 没有访问过时候不需要加上节点间距
            }
            else {
                parentNode.subTreeWidth = parentNode.subTreeWidth + maxSubtreeWidth + nodeGutter;   // 父节点的子树增加并且加上节点间距
            }
        }
    })

    // 准备开始计算每个节点的x, y
    const fromRootToLeaf = JSON.parse(JSON.stringify(data));

    idToX6Map[-1].x = parseInt((idToCalcNodesMap[-1].subTreeWidth) / 2) - (nodeWidth) / 2; // 根树的x结点
    idToX6Map[-1].y = 200;

    fromRootToLeaf.sort((a, b) => {
        // 先按 layer 降序排序
        if (a.layer !== b.layer) {
            return a.layer - b.layer;
        }
        // 同一层内按 pid 升序排序
        return a.pId - b.pId;
    })


    fromRootToLeaf.forEach((node) => {
        // 虚拟根节点不需要计算
        if (node.id === -1) {
            return;
        }
        const calcNode = idToCalcNodesMap[node.id];
        const x6Node = idToX6Map[node.id];
        const pid = calcNode.pId;
        const parentCalcNode = idToCalcNodesMap[pid];
        const parentX6Node = idToX6Map[pid];
        const currentNodeWidth = calcNode.width;
        // 当前节点子树开始的x坐标: 父节点的 x 坐标 + 当前节点的宽度 - 父节点子树宽度 / 2 (居中)+ 父节点已经被使用的宽度(被兄弟节点使用过的宽度)
        //const subTreeStartX = parentX6Node.x + calcNode.width / 2 - parseInt(parentCalcNode.subTreeWidth / 2) + parentCalcNode.usedWidth;
        const subTreeStartX = parentX6Node.x + parseInt(calcNode.width / 2 - parentCalcNode.subTreeWidth / 2) + parentCalcNode.usedWidth;
        // 当前节点子树开始的y坐标: 父节点的 y 左边 + 层间距 + 节点高度
        const subTreeStartY = parentX6Node.y + layerGutter + nodeHeight;

        // 
        const nodeX = parseInt(subTreeStartX + calcNode.subTreeWidth / 2 - calcNode.width / 2);

        x6Node.x = nodeX;
        x6Node.y = subTreeStartY;


        // 当父节点不为虚拟根节点的时候, 添加当前节点、父节点、收缩节点之间的边
        if (parentCalcNode.id !== -1) {
            // 父节点到收缩节点之间的边
            x6Edges.push({
                source: parentX6Node.id,
                target: `expand-${parentX6Node.id}`,
                attrs: {
                    line: {
                        stroke: "#999", // 指定 path 元素的填充色
                        targetMarker: null,
                        sourceMarker: null,
                    }
                },
            })

            // 收缩节点到当前节点之间的边
            x6Edges.push({
                source: `expand-${parentX6Node.id}`,
                target: x6Node.id,
                attrs: {
                    line: {
                        stroke: "#999", // 指定 path 元素的填充色
                    }
                },
                router: {
                    name: 'er',
                    args: {
                        offset: 'center',
                        direction: 'B'
                    },
                }
            })
            // 查看 父节点 是否已经存在 改收缩节点, 不存在则添加
            if (!collapsableNodes[parentCalcNode.id]) {
                collapsableNodes[parentCalcNode.id] = {
                    shape: 'collapsableNode',
                    width: 40,
                    height: 40,
                    // x 为父节点 x 坐标 + 父节点宽度的一半 - 20 (自身宽度的一半)
                    x: parentX6Node.x + parseInt(parentCalcNode.width / 2) - 20,
                    // y 坐标为 父节点的 y 坐标 + 父节点高度 + 层间距 / 5 (可以自由调整,我是想更靠近父节点) - 20 (收缩节点自身高度的一半)
                    y: parentX6Node.y + parentCalcNode.height + layerGutter / 5 - 20,
                    data: {
                        expand: true,  // 默认收缩节点全展开
                        children: [calcNode.id] // 收缩节点控制的所有子节点
                    },
                    ports: {
                        bottom: {
                            position: 'bottom',
                            attrs: {
                                circle: {
                                    magnet: true,
                                    stroke: '#8f8f8f',
                                    r: 5,
                                },
                            },
                        }
                    }
                }
            }
            // 存在 则把 当前节点添加为收缩节点的子节点
            else {
                collapsableNodes[parentCalcNode.id].data.children.push(calcNode.id)
            }
        }


        // 更新父节点已使用的宽度, 用于计算下一个子节点的起始x坐标;
        // 此前未使用过
        if (parentCalcNode.usedWidth === 0) {
            // 未使用过, 暂时不存在 兄弟节点, 不需要
            parentCalcNode.usedWidth = parentCalcNode.usedWidth + calcNode.subTreeWidth + nodeGutter;
        }
        // 此前已经使用过
        else {
            parentCalcNode.usedWidth = parentCalcNode.usedWidth + calcNode.subTreeWidth + nodeGutter;
        }
    })

    return {
        nodes: [...x6Nodes.filter(item => item.id !== -1), ...Object.keys(collapsableNodes).map(key => ({ ...collapsableNodes[key], id: `expand-${key}` }))],
        edges: x6Edges,
        size: {
            width: idToCalcNodesMap[-1].subTreeWidth,
            height: Math.max(...calcNodes.map(item => item.layer)) * (nodeHeight + layerGutter) - layerGutter
        }
    };
}

自定义vue节点

主要是使用了antv/x6提供的基于Teleport的对于vuejs的支持,让开发者可以使用vue组件元素作为节点元素,详细文档可以查看文档 Vue 节点 | X6 (antgroup.com)

收缩节点的实现

image.png

收缩节点链接的子节点数据记录在收缩节点的data中, 再在收缩节点中监听收缩节点自身的visible事件,当收起/展开某个节点时,在visible事件中把收起/展开状态蔓延到刚刚存储在data中的子节点及其链接的收缩节点,这样就可以达到收起/展开时,所有的子孙节点都同步隐藏/显示。

简单讲就是: 通过监听自身节点的visible事件,当点击展开/收起的时候,触发链接子节点的visible事件,再递进蔓延到下一级子节点,达到整个子树展开/收起的效果。

onMounted(() => {
    const node = getNode();
    const graph = getGraph();
    expand.value = node.data.expand;
    node.on("change:data", ({ current }) => {
        expand.value = current.expand;
    });
    // 监听可见事件
    node.on('change:visible', (e) => {
        // 当存在子节点的时候,遍历子节点并显示
        if (node.data.children) {
            node.data.children.forEach(key => {
                const child = graph.getCellById(key);   // 显示链接的子节点
                const expandChild = graph.getCellById(`expand-${key}`)  // 子节点的收缩节点(不一定存在)
                if (node.visible && node.data.expand) {
                    child.show()    // 显示子节点
                    expandChild?.show() // 存在收缩节点也显示
                }
                else {
                    child.hide()    // 隐藏子节点
                    expandChild?.hide() // 子节点存在收缩节点隐藏
                }

                //console.log(child)
            })
        }
    })
})

参考文档

  1. x6基本配置及文档 ———— 简介 | X6 (antgroup.com)

  2. x6使用vue节点 ———— Vue 节点 | X6 (antgroup.com)

拓展思考

这篇demo只做简易树的样例, 可以进行更高难度的拓展,如:

  1. 每个节点的高度可能不一致的情况。本篇demo中只实现了高度一致的情况, 这时候应该要算出每层的高度,并做记录,用作计算。
  2. 类似族谱的树状图。 这就要考虑有伴侣的节点单身节点两种情况了。

如果这篇文章数据好看,会更新复杂点的情形。

创作不易,点赞收藏。

欢迎评论友好交流