Zhihao's Studio.

拓扑图的实现

Word count: 2,014 / Reading time: 9 min
2017/10/18 Share

背景

拓扑图是Console界面中一个重要的组成部分,它的愿景是我公司Console平台相比于其他公司相同平台的创新之处。这个部分一直是由我来负责,从开始的高度抽象派图形化图,到后面创新性的引入Radial-layout,到这轮迭代返璞归真的Tree-layout信息展示,几经迭代,感慨良多。很多时候都要跟设计师死磕,在他的想法与技术之间找到一个权衡点。本文作为本轮迭代的一个小小总结,也作为API文档的供其他同事参考。也因为要作为API文档,因此本文会深入到代码级讲述每段代码甚至函数和关键变量的作用。



早期版本的拓扑图

目标

拓扑图的目标是利用图形化的方式清晰地展示集群内App、Service、Deployment、Pod、Storage之间的关系,并提供交互手段给用户动态的增减Pod,查看某部分的Detail信息等。

概览

最新版本的拓扑图效果如下图所示:



最新版本的拓扑图

请注意,图片中除了后期加上的说明文字和边框外,其他的一切元素都是使用d3.js这个库绘制上去的,包括其中的文字。

从总体上看,拓扑图由三个部分组成,分别是顶部的样例说明区、主体部分的信息展示区、右上角的细节信息展示区。画布名称是vis,这是一个很关键的全局变量。

样例说明区

和另外两个部分不同,样例说明区是静态的,不会因为不同的App而变化。因此这个部分相对简单,涉及到的代码也不是很多。主要的实现方式是首先绘制一个无边框的圆角矩形放置在底部,然后用代码控制圆圈和相应文字的间隔和类型。类型是为了方便将css样式应用到图形上做的准备。核心代码如下:

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
vis.append('rect')
.attr('x',-18)
.attr('y',-30)
.attr('rx',5)
.attr('ry',5)
.attr('width',4*180+120)
.attr('height',30)
.attr('stroke','none')
.style('fill','#f0f0f0');
var exampleData = ['App','Service','Deployment','Pod','Storage'];
var example = vis.selectAll('.example').data(exampleData).enter().append('g');
example.append('circle')
.attr('cy',-15)
.attr('cx',function(d,i){
return 180*(i);
})
.style('fill','#fff')
.attr('r',7)
.attr('class',function(d,i){
if(i == 0){
return 'app'
}else if(i ==1){
return 'service'
}else if(i ==2){
return 'deployment'
}else if(i ==3){
return 'pod'
}else if(i ==4){
return 'storage'
}else{
return 'unclear'
}
});
example.append("text").attr('class','exampleTEXT').attr("x", function(d,i) {
return 180*i+10
}).attr("y", function(d) {
return -15;
}).attr("dy", ".35em").attr("text-anchor", function() {
return "start";
}).text(function(d) {
return d;
}).style("fill-opacity", 1);

主体信息展示区

主体信息的展示是拓扑图的核心部分。各个组件的数量会有很大的变化范围,这也是我们弃用老版本拓扑图的原因。坦率的讲,老版本的拓扑图是比较美观的、图形化的,但是遇到的核心问题是当某些组件数量过大时,容易引起大量的重叠问题,造成整个系统不work了。

而这个版本的拓扑图得益于d3提供的Tree布局方式,比较好的解决了这个问题。它会根据用户指定的画布宽高,算出最适合的布局方式,即每个点应该放置在哪里。

Tree-layout接受的数据形式如下图所示:



Tree-layout接受的数据格式

我们backend提供的json文件与要求的数据格式有较大的差别,下面比较恶心的代码的功能是利用循环将后端提供的json文件转化成Tree-layout需要的格式。在转换过程中,为了实现深拷贝功能,实现了deepClone函数,为方便起见,用了JSON的stringfy和parse方法编码解码。

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
d3.json("topologyRaw.json", function(error, data) {
if (error) throw error;
var jsonData = {name:"nil",children:new Array()};
apps = data.app;
storages = data.storages;
services = data.services;
deployments = data.deployments;
var tmpDeployment;
jsonData.name = data.app.name;
for(var i =0 ;i<services.length;i++){
var service = new Array();
for(var j=0;j<deployments.length;j++){
var deployment = new Array();
if(contains(deployments[j]["selector"]["matchLabels"],services[i]["selector"])){
var pod = new Array();
for(var k=0;k<deployments[j]["pods"].length;k++){
var storage = new Array();
for(var m=0;m<deployments[j]["pods"][k]["storageNames"].length;m++) {
for (var n = 0; n < storages.length; n++) {
if(deployments[j]["pods"][k]["storageNames"][m]==storages[n]["storageName"]) {
if(storages[n].unshared==true && storages[n]["storageType"]=="HostPath")
{
for(var q=0;q<storages[n]["hostpathInfo"].length;q++)
{
storage.push({
name: storages[n]["storageName"],
size: storages[n]["hostpathInfo"][q]["amountBytes"]
})
}
}
else{
storage.push({
name: deployments[j]["pods"][k]["storageNames"][m],
size: getAmountByStorageName(deployments[j]["pods"][k]["storageNames"][m])
})
}
}
}
}
pod.push({name:deployments[j]["pods"][k].name,children:deepClone(storage)});
}
tmpDeployment = deployments[j].name;
}
deployment.push({name:(tmpDeployment),children:deepClone(pod)});
}
jsonData.children.push({name:services[i].name,children:deepClone(deployment)});
}
function deepClone(initalObj) {
var obj = {};
obj = JSON.parse(JSON.stringify(initalObj));
return obj;
}

节点的样式和展开功能

为了让各个组件的样式满足设计师的严苛要求,需要根据Tree-Layout的depth(深度)进行判断,控制圆形的颜色、说明文字出现的位置(放置位置和对齐方式)。这部分逻辑是在nodeEnter、nodeUpdate和nodeExit相关变量后面指定的。也是凭借这三个变量之间的反差,每次页面刚打开并load的时候,会有一个舒展开的样式,非常美观。



展开的过程

节点的开关功能

同样,展开后,后期依然可以控制某个节点的子节点的开合状态,点击某个节点,它的子节点所包含的元素会以动画的形式收缩进它的位置,该节点的中心部分也会由白色变为浅灰色,并且整个布局会相应发生变化。再次点击,会执行相反的操作,收缩进去的部分会再次舒展开。这个逻辑是由toggle和update函数完成的。toggle函数的会将d.children保存到另一个变量_children中,然后将d.children置空(或执行相反操作),然后调用update函数重新绘制Tree的分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
toggle = function(d) {
if(d) {
if (d.children) {
d._children = d.children;
return d.children = null;
} else {
d.children = d._children;
return d._children = null;
}
}
};
update = function(source) {
var duration, link, node, nodeEnter, nodeExit, nodeUpdate, nodes;
duration = d3.event && d3.event.altKey ? 5000 : 500;
nodes = tree.nodes(root).reverse();
nodes.forEach(function(d) {
return d.y = d.depth * 180;
});
node = vis.selectAll("g.node").data(nodes, function(d) {
return d.id || (d.id = ++i);
}).attr('id',function(d){
return d.name;
});

增删pod的功能

增删pod的功能是在每条deployment与pod之间的加入两张预先做好的图片,监听click事件,调用相应的后端API,实现增删的功能。值得注意的是,圆环形、图片、文字说明是depolyment的一个g,点击图片默认情况下会造成deployment关闭状态。因此要在click事件中加入d3.event.stopPropagation();让点击事件不再向下传播。

hover高亮效果

注意到,当鼠标hover到某个元素上时,元素代表的text会高亮,从APP开始经该元素到达storage的路径都将会被高亮。这个部分主要是由highlightOn和highlightOff这两个函数控制的。

在建立路径的时候,我们给每一段路径都加上一个一个id,由path的起始点到path的终点id+name组成,这样就可以唯一确定该path。

id是: path.link.source-“ + d.parent.name+d.parent.id + “.target-“ + d.name+d.id

hightlightOn函数中,有两个重要的子函数,parentLine()和childLine(),这两个函数使用了递归的方式寻找从App到该节点和从该节点到storage节点的路径,并给特定路径指定highlight class,或取消该class的影响。

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
highlightOn = function(d) {
var parentLine;
parentLine = function(d) {
if (d.parent) {
parentLine(d.parent);
vis.selectAll("path.link.source-" + d.parent.name+d.parent.id + ".target-" + d.name+d.id).classed('highlight', true);
}
};
parentLine(d);
var childLine = function(d){
if(d.children && d.children.length>0){
for(var i=0;i<d.children.length;i++){
childLine(d.children[i]);
vis.selectAll("path.link.source-"+d.name+d.id+".target-"+d.children[i].name+d.children[i].id).classed('highlight',true);
}
}
};
childLine(d);
return update(d);
};
highlightOff = function(d) {
vis.selectAll("path.link").classed('highlight', false);
return update(d);
};

detail信息

Detail的信息,之前的博客中写过了,可以参考前面的博文

CATALOG
  1. 1. 背景
  2. 2. 目标
  3. 3. 概览
    1. 3.1. 样例说明区
    2. 3.2. 主体信息展示区
      1. 3.2.1. 节点的样式和展开功能
      2. 3.2.2. 节点的开关功能
      3. 3.2.3. 增删pod的功能
      4. 3.2.4. hover高亮效果
    3. 3.3. detail信息