Vue中使用antv g6实现有默认展开层级的树图

6,464 阅读4分钟

项目中需要实现一个如下图的树图,需要实现的功能包含:节点可展开收齐、默认展开三层节点、节点hover的时候需要tooltip展示更多内容、对于同比信息需要有上升下降的箭头等……

image.png

了解到antv G6可以支持这个,所以就选择了使用G6。

G6图可视化引擎

G6 是一个简单、易用、完备的图可视化引擎,它在高定制能力的基础上,提供了一系列设计优雅、便于使用的图可视化解决方案。能帮助开发者搭建属于自己的图 图分析 应用或是 图编辑器 应用。

核心概念

G6的官方给了一张核心概念的概览图

image.png

从这张图中,我们可以看出G6的核心概念有以下几点:

  • 图 Graph: 初始化和渲染
    在 G6 中,Graph 对象是图的载体,它包含了图上的所有元素(节点、边等),同时挂载了图的相关操作(如交互监听、元素操作、渲染等)。
    Graph 对象的生命周期为:初始化 —> 加载数据 —> 渲染 —> 更新 —> 销毁。

  • 图形(Shape)

    • 图形和属性
    • 关键图形(Key Shape)
    • 图形分组(Shape group)
    • 图形变换(Transform)
  • 图元素(节点、边、Combo)
    图的元素(Item)包含图上的节点 Node 、边 Edge 和 Combo 三大类。每个图元素由一个或多个 图形(Shape) 组成,且都会有自己的唯一关键图形(keyShape)。

    G6 内置了一系列具有不同基本图形样式的节点/边/ Combo,例如,节点可以是圆形、矩形、图片等。G6 中所有内置的元素样式详见 内置节点内置边内置 Combo

    除了使用内置的节点/边/ Combo 外,G6 还允许用户通过自己搭配和组合 shape 进行节点/边/ Combo 的自定义,详见 自定义节点自定义边自定义 Combo

  • 图布局
    图布局是指图中节点的排布方式,根据图的数据结构不同,布局可以分为两类:一般图布局、树图布局。

    除了内置布局方法外,一般图布局还支持 自定义布局 机制。

  • 交互与事件
    除了 内置交互行为  Behavior 和 交互模式 Mode 搭配的事件管理方式外,G6 提供了直接的单个事件、时机的监听方法,可以监听画布、节点、边、以及各函数被调用的时机等。

    如果要了解 G6 支持的所有事件,请参考 Event APIG6 上所有的事件都需要在 graph 上监听

  • 动画
    G6 中的动画分为两个层次:

    • 全局动画:全局性的动画,图整体变化时的动画过渡;
    • 元素(边和节点)动画:节点或边上的独立动画。

G6的使用

了解了G6的一些核心概念之后呢,我们就可以着手开始去使用G6了,在使用之前呢,我们是要先安装的。

  • 使用npm引入

    1. 使用命令行在项目目录下执行以下命令:
        npm install --save @antv/g6
    
    1. 在需要用的 G6 的 JS 文件中导入:
       import G6 from '@antv/g6';
    
  • CDN引入 在html中使用CDN引入

        // version <= 3.2
        <script src="https://gw.alipayobjects.com/os/antv/pkg/_antv.g6-{$version}/build/g6.js"></script>
    
    
        // version >= 3.3
        <script src="https://gw.alipayobjects.com/os/lib/antv/g6/{$version}/dist/g6.min.js"></script>
    
    
        // version >= 4.0
        <script src="https://gw.alipayobjects.com/os/lib/antv/g6/4.3.11/dist/g6.min.js"></script>
    

    📢📢📢

    • 在  {$version} 中填写版本号,例如  3.7.1
    • 最新版可以在  NPM  查看最新版本及版本号;
    • 详情参考 Github 分支:github.com/antvis/g6/t…

避坑

了解和安装之后我们就真的可以开始着手去使用了。

在使用G6的过程中遇到了一些问题,在此提供出来,以便大家在遇到的时候好避坑。

  1. 给树图增加默认展开的层级 如果在使用树的时候请记得使用collapsed这个参数来判断,因为其他属性都不可以,这是G6定义好的😳

image.png 假如说接口数据并没有返回这个参数时怎么办呢(不求人😕)?不要怕,可以使用下面的方法来实现

G6.Util.traverseTree(treeData, function (item) {
    if (item.depth >= 2) {
        //collapsed为true时默认收起
        item.collapsed = true;
    }
});

traverseTree 深度优先遍历树数据

image.png

  1. 当数据没有id时,第一次展开节点时展开的节点不显示 如下图所示,在首次点开收起的层级时,其实展开/收起状态已经改变了,但是节点并没有显示出来(这个问题找了好久,才发现数据中没有id的原因😭)

image.png 假如说你在项目中也遇到了这个问题,那么同样可以使用traverseTree这个函数,给每一个节点增加一个id

G6.Util.traverseTree(treeData, function (item) {
    item.id = utils.generateUUID();
    if (item.depth >= 2) {
        //collapsed为true时默认收起
        item.collapsed = true;
    }
});
  1. G6在放大、缩小以及展开收起时会有虚边

image.png 其实G6也知道在V4.x的版本中会有这个问题,但是也明确提出了在这个版本不会进行修改,当然给了解决方案,我发现最有用的还是

image.png

this.graph.get('canvas').set('localRefresh', false);

如果你在使用过程中还有其他问题,可以看看官方提供的回答中有没有可以解决的常见问题汇总

  1. 在数据量特别大的时候,如果控制节点大小不会改变
    G6官方提供了一个方法,可以使用zoomTo函数把节点缩放到固定的一个比例,禁止改变
// 缩放视窗窗口到一个固定比例,到1就是禁止缩放了,首次绘制时会把图固定在画布的中心
this.graph.zoomTo(1, {x: width / 2, y: height / 2});

image.png

视口的一些其他操作可以参见官网:视口操作

  1. 在节点点击的时候固定把当前节点固定在画布中心
this.graph.on('node:click', (e) => {
    ...
    this.graph.focusItem(e.item); //将当前的节点设置为焦点 
});
  1. 如果节点展开也很大,在画布本身看不全时,又不想去缩小节点时,可以使用miniMap这个组件(配合zoomTo(1)一起使用更好哦)
  2. 如果有条件判断需要更新数据时,一定要清空graph,否则可能绘制多个
// 如果graph存在,若存在就销毁
if (this.graph) {
    this.graph.destroy();
}
this.graph = new G6.TreeGraph({
    container: 'netRevTree',
    ...defaultConfig,
    ...indicatorBoardUtils.config,
    plugins: [miniMap, tooltip],
});

源代码

上面提供了一些使用G6时遇到的问题,如果有想试试的,可以参考一下代码哦~

Vue

<template>
    <div class="net-rev-tree-wrap">
        <div class="tips">
            <div class="tips-parent">
                <span>XX%</span>对父元素的影响占比
            </div>
            <div class="tips-grandfather">
                <span>XX%</span>对父元素的父元素的影响占比
            </div>
        </div>
        <div id="netRevTree"></div>
        <loading :show="loading"/>
    </div>
</template>

<script>
import G6 from '@antv/g6';
import mockData from '@/mock/NetRevTree';
import indicatorBoardUtils from '@/g6/indicatorBoardUtils';

// 这个放在公共的函数文件中
const generateUUID=()=>{
            let d = new Date().getTime();
            const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
                var r = (d + Math.random() * 16) % 16 | 0;  
                d = Math.floor(d / 16);  
                return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
            });
            return uuid;
        },

export default {
    name: 'netRevTree',

    props: {
        treeParams: {
            type: Object,
            default: ()=>({}),
            required: false
        }
    },

    data() {
        return {
            graph: null,
            loading: false,
        };
    },

    watch: {
        treeParams: {
            deep: true,
            handler() {
                this.init();
            }
        }
    },

    methods: {
        init() {
            // 如果graph存在,若存在就销毁
            if (this.graph) {
                this.graph.destroy();
            }

            const container = document.getElementById('netRevTree');
            const width = container.scrollWidth || 800;
            const height = container.scrollHeight || 500;

            const defaultConfig = {
                width,
                height,
                ...indicatorBoardUtils.defaultConfig
            };

            indicatorBoardUtils.registerFn();

            const miniMap = new G6.Minimap({
                size: [150, 100]
            });

            const tooltip = new G6.Tooltip({
                offsetX: 10,
                offsetY: 10, 
                // 允许出现 tooltip 的 item 类型
                itemTypes: ['node'],
                getContent: (e)=>{
                    const outDiv = document.createElement('div');
                    outDiv.style.width = 'fit-content';
                    const {controlIndexName, dataSourceName} = e.item.getModel();
                    const ul =  document.createElement('ul');
                    ul.setAttribute('style', 'line-height:28px;font-size:12px');
                    outDiv.appendChild(ul);
                    const nameLi = document.createElement('li');
                    nameLi.innerText = controlIndexName || '指标全名称';
                    const typeLi = document.createElement('li');
                    typeLi.innerText = dataSourceName || '指标类型';
                    ul.appendChild(nameLi).appendChild(typeLi);
                    outDiv.appendChild(ul);
                    return outDiv;
                }
            });
            this.graph = new G6.TreeGraph({
                container: 'netRevTree',
                ...defaultConfig,
                ...indicatorBoardUtils.config,
                plugins: [miniMap, tooltip],
            });

            G6.Util.traverseTree(mockData.TreeData, function (item) {
                item.id = generateUUID();
                if (item.depth >= 2) {
                    //collapsed为true时默认收起
                    item.collapsed = true;
                }
            });
            this.graph.data(mockData.TreeData);
            this.graph.render();
            // 缩放视窗窗口到一个固定比例,到1就是禁止缩放了
            this.graph.zoomTo(1, {x: width / 2, y: height / 2});

            this.graph.on('node:click', (e) => {
                const {item} = e;
                const node = item?.get('model');
                if (e.target.get('name') === 'collapse-icon') {
                    e.item.getModel().collapsed = !e.item.getModel().collapsed;
                    this.graph.setItemState(e.item, 'collapsed', e.item.getModel().collapsed);
                    this.graph.refreshItem(e.item);
                    this.graph.layout();
                }
                this.graph.focusItem(e.item); //将当前的节点设置为焦点
                
            });
            this.graph.get('canvas').set('localRefresh', false);

            if (typeof window !== 'undefined') {
                window.onresize = () => {
                    if (!this.graph || this.graph.get('destroyed')) return;
                    if (!container || !container.scrollWidth || !container.scrollHeight) return;
                    this.graph.changeSize(container.scrollWidth, container.scrollHeight);
                };
            }
        },
    }
};

</script>

G6配置

import G6 from '@antv/g6';

// 默认配置
const defaultConfig = {
    fitView: true,
    animate: true,
    modes: {
        default: [
            'drag-canvas',
        ],
    },
    defaultNode: {
        type: 'flow-rect',
        style: {
            fill: '#91d5ff',
            stroke: '#40a9ff',
            radius: 5,
        }
    },
    defaultEdge: {
        type: 'flow-line',
        style: {
            stroke: '#CED4D9',
        },
    },
    layout: {
        type: 'compactBox',
        direction: 'LR',
        getWidth: () => 20,
        getVGap: () => 40,
        getHeight: () => 20,
        getHGap: () => 180
    },
};
const colors = {
    down: '#39BF45',
    up: '#E64552',
};

// 自定义节点、边
const registerFn = () => {
    /**
     * 自定义节点
     */
    G6.registerNode(
        'flow-rect',
        {
            draw(cfg, group) {
                const {
                    diffPercent,
                    value,
                    controlName, // 指标名称
                    previousPower, // 父级节点影响力
                    rootPower, // 根节点影响力
                    depth,
                    collapsed
                } = cfg;
  
                const grey = 'rgba(0,0,0,0.15)';
                const rectConfig = {
                    width: 300,
                    height: 74,
                    lineWidth: 1,
                    fontSize: 12,
                    fill: '#fff',
                    radius: 4,
                    shadowColor: 'rgba(0,0,0,0.15)',
                    shadowOffsetX: '2',
                    shadowOffsetY: '2',
                    stroke: grey,
                    opacity: 1,
                };
  
                const nodeOrigin = {
                    x: 0,
                    y: 0,
                };
  
                const textConfig = {
                    textAlign: 'left',
                    textBaseline: 'bottom',
                    stroke: '#fff',
                };

                
                const rect = group.addShape('rect', {
                    attrs: {
                        x: nodeOrigin.x,
                        y: nodeOrigin.y,
                        ...rectConfig,
                    },
                });
  
                const rectBBox = rect.getBBox();
  
                // title
                group.addShape('text', {
                    attrs: {
                        ...textConfig,
                        x: 12,
                        y: 30,
                        text: controlName.length > 15 ? controlName.substr(0, 15) + '...' : controlName,
                        fontSize: 14,
                        fontWeight: 400,
                        fill: '#333',
                        cursor: 'pointer',
                    },
                    name: 'title-shape'
                });
                
                if (depth != 0) {
                    // 对父级影响的占比
                    group.addShape('text', {
                        attrs: {
                            ...textConfig,
                            x: rectConfig.width - 50,
                            y: 30,
                            text: `${((previousPower || 0) * 100).toFixed(2)}%`,
                            fontSize: 12,
                            fill: '#4C84FF',
                            cursor: 'pointer',
                        },
                        name: 'parent-text-shape'
                    });
                    // 对父级的父级影响的占比
                    group.addShape('text', {
                        attrs: {
                            ...textConfig,
                            x: rectConfig.width - 50,
                            y: rectBBox.maxY - 12,
                            text: `${((rootPower || 0) * 100).toFixed(2)}%`,
                            fontSize: 12,
                            fill: '#F27C49',
                            cursor: 'pointer',
                        },
                        name: 'parent-parent-ratio',
                    });
                }
  
                // 钱数
                const price = group.addShape('text', {
                    attrs: {
                        ...textConfig,
                        x: 12,
                        y: rectBBox.maxY - 10,
                        text: (value / 1000000).toFixed(1),
                        fontSize: 16,
                        fontWeight: 'bold',
                        fill: '#000',
                        opacity: 0.85,
                    },
                    name: 'price-shape'
                });
  
                // 单位
                const unit = group.addShape('text', {
                    attrs: {
                        ...textConfig,
                        x: price.getBBox().maxX + 5,
                        y: rectBBox.maxY - 12,
                        text: '百万',
                        fontSize: 12,
                        fill: '#333',
                    },
                    name: 'unit-shape'
                });

                // 同比
                const ratio = group.addShape('text', {
                    attrs: {
                        ...textConfig,
                        x: unit.getBBox().maxX + 10,
                        y: rectBBox.maxY - 12,
                        text: '同比',
                        fontSize: 12,
                        fill: '#666',
                    },
                    name: 'ratio-shape'
                });
  
                // percentage
                const percentText = group.addShape('text', {
                    attrs: {
                        ...textConfig,
                        x: ratio.getBBox().maxX + 5,
                        y: rectBBox.maxY - 12,
                        text: `${((diffPercent || 0) * 100).toFixed(2)}%`,
                        fontSize: 12,
                        fill: diffPercent >= 0 ? '#39BF45' : '#E64552',
                    },
                    name: 'percent-shape'
                });
  
                // percentage triangle
                const arrow = diffPercent < 0 
                    ? require('@/assets/svgs/arrow-down-g6.svg') 
                    : require('@/assets/svgs/arrow-up-g6.svg');

                group.addShape('image', {
                    attrs: {
                        ...textConfig,
                        x: percentText.getBBox().maxX + 2,
                        y: rectBBox.maxY - 12 - 12,
                        width: 12,
                        height: 12,
                        img: arrow,
                    },
                    name: 'arrow-icon'
                });
  
                // 展开收起 rect
                if (cfg.children && cfg.children.length) {
                    group.addShape('marker', {
                        attrs: {
                            x: collapsed ? rectConfig.width : rectConfig.width + 40,
                            y: rectConfig.height / 2,
                            r: 8,
                            cursor: 'pointer',
                            symbol: collapsed ? G6.Marker.expand : G6.Marker.collapse,
                            stroke: '#4C84FF',
                            lineWidth: 1,
                            fill: '#fff',
                        },
                        name: 'collapse-icon',
                    });
                }
                this.drawLinkPoints(cfg, group);
                return rect;
            },
            update(cfg, item) {
                const {collapsed} = cfg;
                const width = 300;
                const marker = item.get('group').find((ele) => ele.get('name') === 'collapse-icon');
                marker.attr('x', collapsed ? width : width + 40);
            },
            setState(name, value, item) {
                if (name === 'collapsed') {
                    const marker = item.get('group').find((ele) => ele.get('name') === 'collapse-icon');
                    const icon = value ? G6.Marker.expand : G6.Marker.collapse;
                    marker.attr('symbol', icon);
                }
            },
            getAnchorPoints() {
                return [
                    [0, 0.5],
                    [1, 0.5],
                ];
            },
        },
        // 继承内置节点类型的名字
        'rect',
    );
    /**
     * 自定义边
     */
    G6.registerEdge('flow-line', {
        draw(cfg, group) {
            const startPoint = cfg.startPoint;
            const endPoint = cfg.endPoint;
      
            const {style} = cfg;
            const shape = group.addShape('path', {
                attrs: {
                    stroke: style.stroke,
                    path: [
                        ['M', startPoint.x, startPoint.y], // 起始位置是上一个节点的可以连接的结束点
                        ['L', (endPoint.x - startPoint.x) / 2 + startPoint.x, startPoint.y], // 结束位置到开始位置中间点
                        ['L', (endPoint.x - startPoint.x) / 2 + startPoint.x, endPoint.y], // 结束位置到开始位置中间点之后的点
                        ['L', endPoint.x, endPoint.y], // 起始位置是后一个节点的可以连接的起始点
                    ],
                },
            });
      
            return shape;
        },
    });
};

const legend = {
    data: {
        nodes: [
            {
                label: 'XX% 对父级影响的占比',
                type: '',
                size: [12, 12],
                style: {
                    fontSize: 14,
                    radius: 3,
                    fill: '#4C84FF',
                },
            },
            {
                label: 'XX% 对父级的父级影响的占比',
                type: '',
                size: [12, 12],
                style: {
                    fontSize: 14,
                    radius: 3,
                    fill: '#F27C49',
                },
            }
        ]
    },
    align: 'center',
    position: 'top-left',
    layout: 'vertical',
    vertiSep: 6,
    horiSep: 12,
    containerStyle: {
        fillOpacity: 0,
        lineWidth: 0,
    },
};

export default {
    defaultConfig,
    registerFn,
    legend
};

mock数据

const TreeData = {
    "controlName": "层级1",
    "depth": 0,
    "diffPercent": null,
    "previousPower": null,
    "rootPower": null,
    "value": 10000000,
    "children": [
        {
            "controlName": "层级2-2",
            "depth": 1,
            "diffPercent": -0.416770018,
            "previousPower": 1.0338731257,
            "rootPower": 1.0338731257,
            "value": 50767870512993.164,
            children: [
                {
                    "controlName": "层级3-1-1",
                    "depth": 2,
                    "diffPercent": -0.416770018,
                    "previousPower": 1.0338731257,
                    "rootPower": 1.0338731257,
                    "value": 50767870512993.164,
                    children: [
                        {
                            "controlName": "层级4-1-1-1",
                            "depth": 3,
                            "diffPercent": -0.416770018,
                            "previousPower": 1.0338731257,
                            "rootPower": 1.0338731257,
                            "value": 50767870512993.164,
                        }
                    ]
                },
                {
                    "controlName": "层级3-1-2",
                    "depth": 2,
                    "diffPercent": -0.416770018,
                    "previousPower": 1.0338731257,
                    "rootPower": 1.0338731257,
                    "value": 50767870512993.164,
                },
                {
                    "controlName": "层级3-1-3",
                    "depth": 2,
                    "diffPercent": -0.416770018,
                    "previousPower": 1.0338731257,
                    "rootPower": 1.0338731257,
                    "value": 50767870512993.164,
                }
            ]
        },
        {
            "controlName": "层级2-2",
            "depth": 1,
            "diffPercent": -0.325708737,
            "previousPower": -0.03432178,
            "rootPower": -0.03432178,
            "value": 2156545193214.912,
            children: [
                {
                    "controlName": "层级3-2-1",
                    "depth": 2,
                    "diffPercent": -0.325708737,
                    "previousPower": -0.03432178,
                    "rootPower": -0.03432178,
                    "value": 2156545193214.912,
                },
                {
                    "controlName": "层级3-2-2",
                    "depth": 2,
                    "diffPercent": -0.325708737,
                    "previousPower": -0.03432178,
                    "rootPower": -0.03432178,
                    "value": 2156545193214.912,
                }
            ]
        },
        {
            "controlName": "层级2-3",
            "depth": 1,
            "diffPercent": 1.6428933841,
            "previousPower": 0.0004486543,
            "rootPower": 0.0004486543,
            "value": 5588826751.994094
        }
    ],
};
export default {
    TreeData,
};