项目中需要实现一个如下图的树图,需要实现的功能包含:节点可展开收齐、默认展开三层节点、节点hover的时候需要tooltip展示更多内容、对于同比信息需要有上升下降的箭头等……
了解到antv G6可以支持这个,所以就选择了使用G6。
G6图可视化引擎
G6 是一个简单、易用、完备的图可视化引擎,它在高定制能力的基础上,提供了一系列设计优雅、便于使用的图可视化解决方案。能帮助开发者搭建属于自己的图 图分析 应用或是 图编辑器 应用。
核心概念
G6的官方给了一张核心概念的概览图
从这张图中,我们可以看出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 API。G6 上所有的事件都需要在 graph 上监听。
-
动画
G6 中的动画分为两个层次:- 全局动画:全局性的动画,图整体变化时的动画过渡;
- 元素(边和节点)动画:节点或边上的独立动画。
G6的使用
了解了G6的一些核心概念之后呢,我们就可以着手开始去使用G6了,在使用之前呢,我们是要先安装的。
-
使用npm引入
- 使用命令行在项目目录下执行以下命令:
npm install --save @antv/g6
- 在需要用的 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的过程中遇到了一些问题,在此提供出来,以便大家在遇到的时候好避坑。
- 给树图增加默认展开的层级
如果在使用树的时候请记得使用
collapsed
这个参数来判断,因为其他属性都不可以,这是G6定义好的😳
假如说接口数据并没有返回这个参数时怎么办呢(不求人😕)?不要怕,可以使用下面的方法来实现
G6.Util.traverseTree(treeData, function (item) {
if (item.depth >= 2) {
//collapsed为true时默认收起
item.collapsed = true;
}
});
traverseTree 深度优先遍历树数据
- 当数据没有id时,第一次展开节点时展开的节点不显示 如下图所示,在首次点开收起的层级时,其实展开/收起状态已经改变了,但是节点并没有显示出来(这个问题找了好久,才发现数据中没有id的原因😭)
假如说你在项目中也遇到了这个问题,那么同样可以使用
traverseTree
这个函数,给每一个节点增加一个id
G6.Util.traverseTree(treeData, function (item) {
item.id = utils.generateUUID();
if (item.depth >= 2) {
//collapsed为true时默认收起
item.collapsed = true;
}
});
- G6在放大、缩小以及展开收起时会有虚边
其实G6也知道在V4.x的版本中会有这个问题,但是也明确提出了在这个版本不会进行修改,当然给了解决方案,我发现最有用的还是
this.graph.get('canvas').set('localRefresh', false);
如果你在使用过程中还有其他问题,可以看看官方提供的回答中有没有可以解决的常见问题汇总
- 在数据量特别大的时候,如果控制节点大小不会改变
G6官方提供了一个方法,可以使用zoomTo
函数把节点缩放到固定的一个比例,禁止改变
// 缩放视窗窗口到一个固定比例,到1就是禁止缩放了,首次绘制时会把图固定在画布的中心
this.graph.zoomTo(1, {x: width / 2, y: height / 2});
视口的一些其他操作可以参见官网:视口操作
- 在节点点击的时候固定把当前节点固定在画布中心
this.graph.on('node:click', (e) => {
...
this.graph.focusItem(e.item); //将当前的节点设置为焦点
});
- 如果节点展开也很大,在画布本身看不全时,又不想去缩小节点时,可以使用miniMap这个组件(配合zoomTo(1)一起使用更好哦)
- 如果有条件判断需要更新数据时,一定要清空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,
};