此文章用于回顾之前使用AntV G6实现需求的过程:了解、熟悉、运用、落地;
分为上下两部分,下一篇文章分享下一个案例。
需求
下图部分就是此次需要使用AntV G6
实现的部分,下图是整个页面的左侧部分,能够收起和展开;其中的图形部分具有以下功能:
- 支持整体缩放、拖动
- 图中节点图片由后端数据决定
- 图中右侧节点下方的文本、中间节点的上下文本、右侧节点右边的文本由后端数据动态渲染
- 节点连接关系由后端数据决定
- 点击绿色图标有
tooltip
效果,内容由数据渲染,再次点击tooltip
的内容能够触发事件作用于外部
以下内容着重描述图形部分实现,面板收缩功能不在此次讨论范围内。
ps:示意图中数据为模拟数据。
选型
在接到这个需求的时候,在当时团队的技术栈中,ECharts
看似能解决这个需求。但在对ECharts
调研之后,发现它并不能处理这种自定义程度比较高的任务。团队中也没有相关的技术能处理这个需求,这时就需要接入一个新的技术。
这里我就省去查找技术的过程,直接说答案,在这里我选择了使用AntV G6来处理这个需求,原因有以下几点:
- 大厂出品
- 持续维护
- 社区良好
- 文档完善
其中主要是「文档完善」这一点在我这里比重很大,因为当时这个需求的时间不充裕,完善的文档能提高开发效率。
接入AntV G6过程
熟悉文档
接入一个新技术,当然首先要了解并熟悉。在官方文档中,需要先掌握以下内容:
在阅读完上述文档之后,就能够对G6
有一个基础的认识:
- 图
- 节点
- 边
- 布局
- 交互
- 管理
- 行为
- 状态
- 插件
- 动画
注意事项: 这里要特别指出,可以优先阅读FAQ
部分,这里面总结了不同技术栈在使用G6
时遇到的问题,阅读此部分能够判断当前技术栈是否能引入该技术。
编写基础案例
经过熟悉之后,我就先把技术引入到项目中,并且按照需求图的布局方式尝试写一个小案例,以达到熟悉的目的:
ps: 基础案例背景色我习惯用pink
,是因为我的启蒙老师是pink
老师
html
<template>
<div class="container">
<div class="g6" id="g6"></div>
</div>
</template>
<script setup>
import G6 from '@antv/g6';
import {onMounted} from 'vue';
const initData = {
// 点集
nodes: [
{
id: 'node1', // 节点的唯一标识
label: '1', // 节点文本
},
{id: 'node2', label: '2'},
{id: 'node3', label: '3'},
{id: 'node4', label: '4'},
{id: 'node5', label: '5'},
{id: 'node6', label: '6'},
{id: 'node7', label: '7'},
],
// 边集
edges: [
{
source: 'node1', // 起始点 id
target: 'node2', // 目标点 id
},
{source: 'node2', target: 'node3'},
{source: 'node1', target: 'node4'},
{source: 'node4', target: 'node5'},
{source: 'node1', target: 'node6'},
{source: 'node6', target: 'node7'},
],
};
onMounted(() => {
const graph = new G6.Graph({
container: 'g6', // 指定挂载容器
width: 500, // 图的宽度
height: 500, // 图的高度
layout: {
type: 'dagre',
// 布局方向
rankdir: 'LR',
// 节点对齐方式
align: 'DL',
// 是否保留布局连线的控制点
controlPoints: true,
// nodesep 竖直间距
nodesepFunc: () => 20,
// ranksep 水平方向层间距
ranksepFunc: () => 34,
},
});
graph.data(initData); // 加载数据
graph.render(); // 渲染
});
</script>
<style lang="less" scoped>
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.g6 {
width: 500px;
height: 500px;
background-color: pink;
}
}
</style>
剩余问题
目前撰写的案例和需求的要求相比,还有以下几个点需要处理:
- 自定义节点
- 自定义边
- 实现tooltip
- 定义数据结构
- 封装
这些比较深入的部分就需要阅读「核心概念部分」之后才能实现,核心概念总览如下:
在这里需要重点阅读以下内容:
- 图(基础定义)
- 图形(基础定义)
- 图布局(实现图布局)
- 交互与事件(实现交互)
- 插件(实现tooltip)
- 节点(实现自定义节点)
- 边(实现自定义边)
自定义节点
三种节点的样式都较为相近,都是以图片为核心,周围加上文字修饰,所以我就准备在内置的image
节点基础上来绘制节点:
- 注册节点
- 绘制节点
主要是利用了内置的text
和image
节点,设置好位置之后,返回image
关键图形;其中根据节点数据中的nodeType
字段来判断添加的文本内容以及渲染相应的图片
js
G6.registerNode('dom-node-1', {
/**
* 绘制节点,包含文本
* @param {Object} cfg 节点的配置项
* @param {G.Group} group 图形分组,节点中图形对象的容器
* @return {G.Shape} 返回一个绘制的图形作为 keyShape,通过 node.get('keyShape') 可以获取。
* 关于 keyShape 可参考文档 核心概念-节点/边/Combo-图形 Shape 与 keyShape
*/
draw: (cfg, group) => {
// nodeType自定义数据,用于区分节点
switch (cfg.nodeType) {
case 'bbu':
// 添加下方文本
group.addShape('text', {
attrs: {
x: -10,
y: 80,
fontSize: 12,
fontWeight: 'bold',
fill: '#292d33',
text: cfg[cfg.nodeType].enodebId ?? '--',
},
});
break;
case 'rru':
// 添加上方文本
group.addShape('text', {
attrs: {
x: -10,
y: 80,
fontSize: 12,
fontWeight: 'bold',
fill: '#292d33',
text: cfg[cfg.nodeType].rruCode ?? '--',
},
});
// 添加下方文本
group.addShape('text', {
attrs: {
x: 20,
y: -7,
fontSize: 14,
fontWeight: 'bold',
fill: '#1a988e',
text: cfg[cfg.nodeType].vendor ?? '--',
},
});
break;
case 'antenna':
// 添加右侧DOM结构
group.addShape('dom', {
attrs: {
x: 68,
y: 13,
width: 73,
height: 50,
html: `
<div style="width: 73px;height: 50px;display: grid;grid-template-columns: repeat(2, 1fr);grid-template-rows: repeat(2, 1fr);font-weight: bold;">
<div>e:${cfg.antenna.electronDowndip ?? '--'}</div>
<div style="text-align: right;">m:${cfg.antenna.mechanicalDowndip ?? '--'}</div>
<div>a:${cfg.antenna.azimuth ?? '--'}</div>
<div style="text-align: right;">h:${cfg.antenna.antennaHeight ?? '--'}</div>
</div>
`,
},
draggable: true,
});
break;
default:
break;
}
// 返回关键图形「image」
return group.addShape('image', {
attrs: {
x: 0,
y: 0,
width: 75,
height: 65,
// imgData为封装的图片数据
img: imgData[cfg.nodeType],
},
name: 'image-shape',
});
},
// 调整锚点 anchorPoint,确定节点与边的相交的位置
getAnchorPoints() {
return [
[0, 0.5], // 左侧中间
[1, 0.5], // 右侧中间
];
},
});
自定义边
由于给出的需求图中的边与G6
默认边差距较大,所以这里也需要自定义边(包含边、箭头)。
实现这部分需要先阅读:
- 图形样式属性 Shape Attr:定义样式
- MDN SVG path知识:边和箭头绘制
第一个自定义边以及自定义箭头:
js
G6.registerEdge('lk-line-one', {
/**
* 绘制节点,包含文本
* @param {Object} cfg 节点的配置项
* @param {G.Group} group 图形分组,节点中图形对象的容器
* @return {G.Shape} 返回一个绘制的图形作为 keyShape
*/
draw(cfg, group) {
// 开始节点
const startPoint = cfg.startPoint;
// 结束节点
const endPoint = cfg.endPoint;
// 图形
const shape = group.addShape('path', {
attrs: {
// 描边颜色
stroke: '#f7b551',
// path是svg里面的path,M是移动画笔,moveto的缩写,L是lineto的缩写,线移动
// 大写代表绝对定位,小写代表相对定位
path: [
['M', endPoint.x / 4 + (2 / 3) * startPoint.x, startPoint.y],
['L', endPoint.x / 3 + (2 / 3) * startPoint.x, startPoint.y],
['L', endPoint.x / 3 + (2 / 3) * startPoint.x, endPoint.y],
['L', endPoint.x, endPoint.y],
],
// 描边虚线,Number[] 类型中数组元素分别代表实、虚长度
lineDash: [5],
// 描边宽度
lineWidth: 2,
// 开始箭头
startArrow: {
// 填充色
fill: '#000',
// 透明度
opacity: 0.5,
// path是svg里面的path,M是移动画笔,moveto的缩写,L是lineto的缩写,线移动。a是弧形
// 从坐标0,0开始画两个弧形凑成一个空心圆
path: 'M 0,0 a 2 2,0,1,1, 2 2 a 2 2, 0,1,1, 2, -2',
},
// 结束箭头
endArrow: {
// 用线构建一个箭头
path: 'M 0,0 l -3,3 l 0,0 l 3,-3 l -3,-3 l 0,0 l 3,3 Z',
},
},
// 在 G6 3.3 及之后的版本中,必须指定 name,可以是任意字符串,但需要在同一个自定义元素类型中保持唯一性
name: 'edge-shape',
});
return shape;
},
});
第二个自定义边以及自定义箭头:
js
G6.registerEdge('lk-line-two', {
/**
* 绘制节点,包含文本
* @param {Object} cfg 节点的配置项
* @param {G.Group} group 图形分组,节点中图形对象的容器
* @return {G.Shape} 返回一个绘制的图形作为 keyShape
*/
draw(cfg, group) {
// 开始节点
const startPoint = cfg.startPoint;
// 结束节点
const endPoint = cfg.endPoint;
// 图形
const shape = group.addShape('path', {
attrs: {
// 描边
stroke: '#eb6877',
// path是svg里面的path,M是移动画笔,moveto的缩写,L是lineto的缩写,线移动
// 大写代表绝对定位,小写代表相对定位
path: [
['M', endPoint.x / 4 + (2 / 3) * startPoint.x, startPoint.y],
['L', endPoint.x / 4 + (2 / 3) * startPoint.x, startPoint.y],
['L', endPoint.x / 4 + (2 / 3) * startPoint.x, endPoint.y],
['L', endPoint.x, endPoint.y],
],
// 描边虚线,Number[] 类型中数组元素分别代表实、虚长度
lineDash: [5],
// 描边宽度
lineWidth: 2,
// 结束箭头
endArrow: {
// 用线构建一个箭头
path: 'M 0,0 l -3,3 l 0,0 l 3,-3 l -3,-3 l 0,0 l 3,3 Z',
},
},
// 在 G6 3.3 及之后的版本中,必须指定 name,可以是任意字符串,但需要在同一个自定义元素类型中保持唯一性
name: 'path-shape',
});
return shape;
},
});
实现tooltip
到这一个步骤的时候,其实整体样式已经实现了,最后还差一个tooltip的功能,具体使用方式在这里;G6
插件的使用方式类似于Webpack
里面的插件,也是需要实例化之后传到plugins
数组中。
js
const tooltip = new G6.Tooltip({
// tooltip 的 x 方向偏移值,需要考虑父级容器的 padding
offsetX: 10,
// tooltip 的 y 方向偏移值,需要考虑父级容器的 padding
offsetY: 20,
// 取出nodeType,控制是否显示tooltip
shouldBegin(e) {
const {
item: {
_cfg: {
model: {nodeType},
},
},
} = e;
const judgeAction = {
bbu: false,
rru: true,
antenna: false,
};
return judgeAction[nodeType];
},
// tooltip 内容,支持 DOM 元素或字符串
getContent(e) {
const {
item: {
_cfg: {
model: {nodeType},
},
},
} = e;
if (nodeType === 'rru') {
const {
item: {
_cfg: {
model: {
rru: {cellList},
},
},
},
} = e;
const cellHtmlStr = cellList.map((item) => {
return `<div class="rru-item">${item.cellkey}</div>`;
});
const outDiv = document.createElement('div');
outDiv.className = 'rru-container';
outDiv.innerHTML = cellHtmlStr.join('');
setTimeout(() => {
const cell = document.getElementsByClassName('rru-item');
Array.from(cell, (item, ind) => {
item.onclick = (e) => {
// fn是外部传进来的,响应外部事件
fn(cellList[ind].cellName, cellList[ind].cellkey);
};
});
}, 100);
return outDiv;
}
return '';
},
trigger: 'click',
itemTypes: ['node'],
});
最终图布局配置:
js
new G6.Graph({
// 传入的渲染节点id
container: id,
// 传入的DOM ref
width: ref.clientWidth,
height: ref.clientHeight,
// 自适应画布
fitView: true,
// 自适应画布时四周留白像素值,fitView为true时生效
fitViewPadding: [0, 20, 20, 10],
layout: {
// 布局类型
type: 'dagre',
/*
说明:布局的方向。T:top(上);B:bottom(下);L:left(左);R:right(右)。
'TB':从上至下布局;
'BT':从下至上布局;
'LR':从左至右布局;
'RL':从右至左布局。
*/
rankdir: 'LR',
/*
说明:节点对齐方式。U:upper(上);D:down(下);L:left(左);R:right(右)
'UL':对齐到左上角;
'UR':对齐到右上角;
'DL':对齐到左下角;
'DR':对齐到右下角;
undefined:默认,中间对齐。
*/
align: 'DL',
// 是否保留布局连线的控制点
controlPoints: true,
// nodesep 竖直间距
nodesepFunc: () => 20,
// ranksep 水平方向层间距
ranksepFunc: () => 34,
},
// 交互管理,一个mode是多种行为Behavior的组合
modes: {
// 缩放画布、拖拽画布
default: ['zoom-canvas', 'drag-canvas'],
},
plugins: [tooltip],
renderer: 'svg', // 使用 Dom node 的时候需要使用 svg 的渲染形势
defaultNode: {
type: 'dom-node-1',
},
fitCenter: true,
});
定义数据结构
整体数据结构是一个对象,有两个数组:nodes
、edges
,代表节点数据和边数据;其中未标明自定义字段 的都是必要字段。
js
const imitateData = {
// 点集
nodes: [
{
// 节点唯一标识,类型为String
id: 'node1',
// 节点类型,默认为circle
type: 'dom-node-1',
// 自定义字段
nodeType: 'bbu',
// 自定义字段
bbu: {enodebId: 'XX-679021'},
},
{
// 节点唯一标识,类型为String
id: 'node2',
// 节点类型,默认为circle
type: 'dom-node-1',
// 自定义字段
nodeType: 'rru',
// 自定义字段
rru: {rruCode: '210108434518', vendor: 'L1.8G', cellList: [{cellkey: '111'}, {cellkey: '222'}]},
},
{
// 节点唯一标识,类型为String
id: 'node3',
// 节点类型,默认为circle
type: 'dom-node-1',
// 自定义字段
nodeType: 'antenna',
// 自定义字段
antenna: {electronDowndip: '4', mechanicalDowndip: '3', azimuth: '85', antennaHeight: '23'},
},
......
],
// 边集
edges: [
{
// 自定义字段
id: 'edge1',
// 边类型
type: 'lk-line-one',
// 开始节点,对应节点id
source: 'node1',
// 结束节点,对应结束id
target: 'node2',
},
{
// 自定义字段
id: 'edge2',
// 边类型
type: 'lk-line-two',
// 开始节点,对应节点id
source: 'node2',
// 结束节点,对应结束id
target: 'node3',
},
......
],
};
整合封装
到这一步,使用G6
做图的任务已经完成了,剩下的任务就是将其整合封装为一个函数并导出:
代码
受限于篇幅问题,我将此次案例的代码,放在了github gist(打不开则需要科学上网)。目录结构如下:
以上代码不包含图片文件,如果需要完整实例,请访问github Demo(打不开则需要科学上网)。
总结
以上就是此次实现G6
的全部过程,其中的难点主要集中在自定义节点和边 的部分,除了熟悉文档相关内容,还需要对SVG path
掌握一定的知识。朋友们在初次使用这个技术来实现需求的时候,可以借鉴我的实现路线,我把需要掌握的文档中的内容都用链接🔗的形式标注出来了。
总体来说是一个比较愉快的过程。
再啰嗦一句,「文档写的好,使用者用起来很轻松👻」
ok,就这样!下一篇文章再见!