最近业务要实现一个拓扑图,之前接触数据可视化工具较少,尝试了多种相关实现工具,在此记录并总结。阅读本文可以了解:
- 常见拓扑图实现工具优劣势对比
- 通过一个例子,学习实现常见拓扑图的方式和小技巧
需求
展示效果:
hover 高亮链路效果:
要点
- 布局:节点组x轴分为四部分(可能不均分),y轴垂直居中,存在无连线的孤立节点
- 交互需求:鼠标hover时要高亮整条链路
技术选型
拓扑图开发工具尝试
尝试顺序由上至下
Echarts
- 尝试理由:
- 中文文档,上手容易
- 关系图 - 节点图 接近需求,封装功能全面,hover效果可以低成本实现
- 可由开发者维护节点位置,方便处理孤立节点
- 放弃理由:
- 文字label不能扩大节点体积,导致连线会盖住文字,如图:
- 尝试解决方案1:文字放下方,本需求不适用(太丑)
- 尝试解决方案2:text-to-svg ,让文字成为symbol的一部分
- 每个动态的text转svg,性能不好
- symbolSize的限制:会导致无论多长的文字都缩放到同样宽度
- 文字label不能扩大节点体积,导致连线会盖住文字,如图:
HighCharts
- 尝试理由:
- svg版的echarts(也可以说:echarts是canvas版的highcharts),希望用灵活的svg解决echarts节点热区的问题
- 放弃理由:
- 甚至没有合适的关系图
- 备注:
- HighCharts和Echarts 本质上是一类东西,跟 d3.js 等工具的维度不同。它们自带的图表类型能满足你最好,满足不了的话就只能自己造轮子了
JTopo
- 尝试理由:
- 看名字就知道,专门用来绘制拓扑图,覆盖类型很广
- 可由开发者维护节点位置,方便处理孤立节点
- 上手容易,文档极其友好
- 放弃理由:
- 尝试绘制后发现,依然有echarts的问题,文字label不能扩大节点体积。根本问题还是节点的定制不够自由
- 尝试绘制后发现,依然有echarts的问题,文字label不能扩大节点体积。根本问题还是节点的定制不够自由
- 备注:
- 国人开发,轻量级的图表库,很推荐大家了解下
Darge-d3
- 尝试理由:
- 基于d3的有向无环图绘制工具,相比d3较容易上手,不需要自己造轮子了
- 给定节点间关系,可自动化生成拓扑图,无需维护节点坐标
- 放弃理由:
- 由于自动生成坐标,孤立的节点会和拓扑图并列(孤立节点被认为是另一张拓扑图),难以控制。
- 如果要用它强行实现,要前端补充孤立节点的关系(仅用于绘制),再隐藏掉连线
- 备注:
- 自动化布局的功能很强大,对于没有孤立节点的需求,可以尝试。看个Demo
G6
- 尝试理由:
- 团队内有相关实现“故障大盘链路图”,很接近本需求
- 插件化,官方没有的功能可以自己写插件实现
- 放弃理由:
- 和故障大盘开发者交流下,了解到有下面两个问题,故而没有尝试:
- g6文档很少比d3还难学(指2.0的文档)
- 孤立节点不好处理(当时主观的猜测g6的实现方式类似darge-d3,不能控制点的位置,实际并不是,可以自己布局)
- 应该检讨自己,听说有些坑就不去尝试,错过了一个好工具。最终使用JointJS实现了需求,现在回头再看g6文档,发现g6的3.0文档比2.0改善了不少,而且是中文文档,核心概念和Joint相似;插件化很强大,缺啥功能自己补~
- 和故障大盘开发者交流下,了解到有下面两个问题,故而没有尝试:
- 备注:
- G6的插件化可以做很多事,比如
- 借助dagre-d3来布局,还能利用插件把原本自动生成的折线改为直线等
- 实现vueComponent插件,可以写vue组件节点,实现更复杂的样式和功能。用Joint实现html element 就比较繁琐
- G6的插件化可以做很多事,比如
JointJS
- 尝试理由:
- 基于svg,高度自定义的element和link终于能满足需求了
- 能够自己维护节点坐标,解决孤立节点的问题
- 文档全面,社区资料不算少
- 放弃理由:
- 这次没有放弃,用它实现了需求
- 备注:
- 不要被它貌似收费的官网迷惑,Rappid的JointJS的收费升级版,而JointJS本身是开源免费的
- JointJS内置的交互工具也很强大,处理强交互的需求很合适,看看它的 Demo
JointJS FAQ
- 为啥不再试试其他的库,比如 d3.js ?
- 时间紧,d3上手难度较高,原计划作为最后的防线,但在尝试到joint.js时发现可以满足需求,就没有选择d3及其他的库;
- 但是d3确实是可以实现的,看个随便搜的Demo
- API看不懂,找不到:JointJS文档虽全,但是API目录结构和搜索的体验不太好。建议学习方式如下:
- 结合 jointjs-api 看 demo,如果在 api 中搜不到demo中的实现方法,去看下源码。如 org 图中new joint.shapes.org.Member,看似官方api,实际是自定义元素(custom elements)
- 搜不到想要的功能,google一下,JointJS的社区讨论相对较多
- npm包ts类型不全:
- 因为部分类型依赖 backbone,需要手动添加。surprise!解决方案:Incompatible TypeScript definitions in jointjs 2.0.1 #797
- 想了解下JointJS核心概念
- paper和graph
- paper即画布,图形将在paper上绘制
- graph即图形数据,一个graph可与多个paper绑定,对graph的修改会即时反映到paper上
- cellView和cell
- cellView: 视图元素,是paper的基本元素,用来处理UI交互,cellView可以通过.model属性获取它的cell
- cell: 图形元素,是graph的基本元素,用来存储图形元素数据,graph其实就是cell的集合
- link和element
- cell有两种类型,link是连线,element是节点,他们的视图元素对应为linkView和elementView
- source和target
- 即连线的起点和终点
- paper和graph
实现要点
注:本文示例代码均为typescript
element & link 样式实现
Cell.define:JointJS支持高度自定义的cell,element和link都是svg绘制的cell视图元素,大部分样式用svg attr结合api即可实现
如何实现动态的element宽度?
由于svg的宽度不能像div那样根据内容自动撑开,这里我的解决方案
1
2// 根据element内部String.length * 单个字符px + padding(留白和图片的宽度和)
size: { width: name.length * 7.2 + 20, height: 20 }你可能会问不同字符宽度不一样怎么办?
- 使用等宽字体可解决,如下图:不等宽字体 (左),等宽字体(右)
- 使用等宽字体可解决,如下图:不等宽字体 (左),等宽字体(右)
节点布局
- 实现节点组垂直居中思路:
- 计算图整体高度totalHeight: gap * (nodeNum - 1)
- 定位中心点,topNode, bottomNode:考虑nodeNum为奇数偶数节点两种情况
- 奇数
- 中心点/topNode/bottomNode:totalHeight / 2
- 偶数
- 中心点:totalHeight / 2
- topNode: totalHeight / 2 - gap / 2
- bottomNode: totalHeight / 2 + gap / 2
- 奇数
- 循环列表,依次更新topNode, bottomNode
按上述思路计算Y坐标,上代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43// 为满足绘制顶部分类label的需求,添加了offset偏移
getNodeCoordY (maxHeight: number, num: number, gap: number, offset: number = 80) {
if (num < 1) return [];
if (gap * (num - 1) > maxHeight - offset) {
console.error('Node height exceeds maximum height~');
return [];
}
let result = [];
let top = 0;
let bottom = 0;
// 未分配数量
let leftNum = num;
// 定位中心点
if (num % 2 === 1) {
// (maxHeight - offset) / 2 + offset
top = bottom = (maxHeight + offset) / 2;
result.push(top);
leftNum -= 1;
} else {
// (maxHeight - offset) / 2 - gap / 2 + offset;
top = (maxHeight + offset - gap) / 2;
bottom = (maxHeight + offset + gap) / 2;
result.push(top);
result.push(bottom);
leftNum -= 2;
}
if (leftNum < 1) return result;
// 定位剩余点
let toggle = true;
for (let i = 0; i < leftNum; i++) {
if (toggle) {
top = top - gap;
result.push(top);
} else {
bottom = bottom + gap;
result.push(bottom);
}
toggle = !toggle;
}
return result.sort((a, b) => a - b);
}X坐标默认按num均分宽度,支持调整分配比例
1
2
3
4
5
6
7
8
9
10
11
12
13getNodeCoordX (maxWidth: number, num: number, proportion?: number[], offset: number = 20) {
if (maxWidth <= offset) return [];
let result = [];
const gapNum = proportion ? proportion.reduce((pre, curr) => pre + curr) : num;
const gap = Math.floor((maxWidth - offset) / gapNum);
// 定位第一个点
let start = offset;
for (let i = 0; i < num; i++) {
result.push(start);
start += proportion ? gap * proportion[i] : gap;
}
return result;
}
实现 hover 高亮链路
- 思路
- 初始化highlightNodes: node[],highlightLinks: link[],两个数组,用于存储需要高亮的节点和链路
- link和element(即节点,项目中命名为node)本质都是svg元素,监听其mouseover/mouseout事件,借助svg可以灵活绑定class的特点,可通过统一 添加/删除class 实现 高亮/取消高亮 的效果。
- cell:mouseover时:以event.target为root节点
- 向前递归:寻找sourceNode和sourceLink
- 若root是link,寻找该link的sourceNode存入highlightNodes
- 若root是node,寻找以该node为target的link存入highlightLinks
- 递归过程会循环出现link -> node -> link,直到源头node为止
- 向后递归:寻找targetNode和targetLink
- 原理同上,递归过程会循环出现link -> node -> link,直到终点node为止
- 收集到需要高亮的node和link,添加高亮;
- 向前递归:寻找sourceNode和sourceLink
- cell:mouseout:移除highlightNodes,highlightLinks中node和link的高亮,并清空两个数组
- 注:对于
<g>
元素cell:mouseover/mouseout会在鼠标经过其每个子元素时反复触发,可以通过svg原生的 pointer-events属性,标识需要触发事件的元素
关键代码(这里借助了部分JointJS api,但思路是相通的,可以同样应用到d3,g6中)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94// 给所有cell绑定事件
this.paper.on('cell:mouseover', this.handleCellMouseOver);
this.paper.on('cell:mouseout', this.handleCellMouseOut);
handleCellMouseOver(cellView) {
this.addHighlight(cellView);
}
handleCellMouseOut() {
this.removeHighlight();
}
// 向前递归,收集source
findSourceCell(cell) {
if (cell.isLink()) {
if (!this.highlightLinks.includes(cell)) this.highlightLinks.push(cell);
const sourceNode = cell.getSourceElement();
if (!sourceNode) return;
this.findSourceCell(sourceNode);
} else {
if (!this.highlightNodes.includes(cell)) this.highlightNodes.push(cell);
const links = this.graph.getConnectedLinks(cell, {
inbound: true
});
if (!Array.isArray(links) || links.length < 0) return;
links.forEach(link => this.findSourceCell(link));
}
}
// 向后递归,收集target
findTargetCell(cell) {
if (cell.isLink()) {
if (!this.highlightLinks.includes(cell)) this.highlightLinks.push(cell);
const sourceNode = cell.getTargetElement();
if (!sourceNode) return;
this.findTargetCell(sourceNode);
} else {
if (!this.highlightNodes.includes(cell)) this.highlightNodes.push(cell);
const links = this.graph.getConnectedLinks(cell, {
outbound: true
});
if (!Array.isArray(links) || links.length < 0) return;
links.forEach(link => this.findTargetCell(link));
}
}
// 添加高亮
addHighlight(rootCellView) {
this.highlightNodes = [];
this.highlightLinks = [];
const root = rootCellView.model;
// 收集高亮Cell
this.findSourceCell(root);
this.findTargetCell(root);
// 先降低全部Cell的透明度
this.allCells.forEach(cell => {
const cellView = this.paper.findViewByModel(cell);
cellView.highlight(null, {
highlighter: {
name: 'addClass',
options: {
className: 'less-opacity'
}
}
});
});
// 恢复需要高亮Cell的透明度
[...this.highlightNodes, ...this.highlightLinks].forEach(cell => {
const cellView = this.paper.findViewByModel(cell);
// 自定义的Options需要在unhighlight方法中声明,不然无法正确移除
cellView.unhighlight(null, {
highlighter: {
name: 'addClass',
options: {
className: 'less-opacity'
}
}
});
});
}
// 移除高亮
removeHighlight() {
// 恢复全部Cell的透明度
this.allCells.forEach(cell => {
const cellView = this.paper.findViewByModel(cell);
// 自定义的Options需要在unhighlight方法中声明,不然无法正确移除
cellView.unhighlight(null, {
highlighter: {
name: 'addClass',
options: {
className: 'less-opacity'
}
}
});
});
// 清空高亮Cell列表
this.highlightNodes = [];
this.highlightLinks = [];
}实现效果
总结
- 按目前的调研来看,本次需求用JointJS,G6,D3都能实现,相比之下
- JointJS的优势是内置有灵活的交互工具,封装的基础API全面
- G6有中文文档更好理解,插件化提供了更多可能。思想和JointJS类似,理解一个就能较快上手另一个。
- D3自由度最高但是实现起来就更复杂,上手难度较高,我又一次避(cuo)开(guo)了d3,也许总有一天会有复杂的需求让我不得不学习它。
- 广泛调研,不要畏难,勇于尝试,可能就会发现更优质的工具和实现思路。总结出共通的思想后,可能只用svg也写得出来了。