在看这篇文章之前,请务必看完上篇文章------手把手教你使用d3.js画一个股权穿透图
前言
大噶吼啊,我是吴小远,距离上一次更新都十一个月了,四舍五入都一年了。你要说我为啥这么久都不更新,那确实是肚子里没货了,而我又不想写什么水文,就这么拖着了。
话说回来,我为什么突然想要对之前写的股权穿透图进行优化呢?就是因为我发现之前我写的那篇股权穿透图有一个重大缺陷,而这个缺陷我直到前段时间又做了一个类似项目的时候才发现。所以,我就有点纳闷,这么久了,咋没有人跟我留言说有BUG...不过,除了修BUG之外,我还增加了一些优化,诸如给节点添加阴影 、点击时使节点居中 、显示完整的节点名称不超出隐藏等;
一、展开收缩的时候节点乱飞
首先就是展开收缩的时候节点乱飞的BUG。如图所示:
之前之所以没有发现这个BUG,是因为节点的数据结构比较简单且没有重复的节点。但是,一旦数据比较复杂且包含交叉持股节点嵌套的时候就会出现这个问题。就如上图中京海文旅投资 既是京海控股集团的直接控股公司,又是间接控股公司,当点击直接控股节点的收缩按钮的时候就会出现这种情况。
那么我们看一下,现在让后端童鞋返回的数据结构是什么样的:
js
let parentsData = [
{
"id": "BG00001",
"parentsIdList": "JH00001,JH00002,JH00003,JH00004",
"name": "京海控股集团有限公司",
"percent": "100",
"nodeColor": "#33ccff",
},
{
"id": "JH00001",
"parentsIdList": "",
"name": "高启强",
"percent": "40.00",
"nodeColor": "#99ff99",
},
{
"id": "JH00002",
"parentsIdList": "",
"name": "高启盛",
"percent": "30.00",
"nodeColor": "#99ff99",
},
{
"id": "JH00003",
"parentsIdList": "JHZF00005,JHZF00006",
"name": "京海文旅投资有限公司",
"percent": "20.00",
"nodeColor": "#99ff99",
},
{
"id": "JH00004",
"parentsIdList": "JH00003",
"name": "京海昌盛文化股份有限公司",
"percent": "10.00",
"nodeColor": "#99ff99",
},
{
"id": "JH00003",
"parentsIdList": "JHZF00005,JHZF00006",
"name": "京海文旅投资有限公司",
"percent": "80.00",
"nodeColor": "#99ff99",
},
{
"id": "JHZF00005",
"parentsIdList": "",
"name": "京海财政厅",
"percent": "30.00",
"nodeColor": "#ffde3a",
},
{
"id": "JHZF00006",
"parentsIdList": "",
"name": "京海国资委",
"percent": "70.00",
"nodeColor": "#ffde3a",
},
{
"id": "JHZF00005",
"parentsIdList": "",
"name": "京海财政厅",
"percent": "30.00",
"nodeColor": "#ffde3a",
},
{
"id": "JHZF00006",
"parentsIdList": "",
"name": "京海国资委",
"percent": "70.00",
"nodeColor": "#ffde3a",
}
]
由此可以看出,当出现重复节点的时候,节点会出现你无法解释的乱飞现象。而且,通过查看数据,我们又能发现一个问题,京海昌盛文化 的父级应该是其下方的那个京海文旅,占股80的那个。但是实际在图中的却是占股20的那个。这就意味着我们的数据处理也出了问题。
那么,我们该怎么解决这个问题呢?
首先,我们先解决数据组装的问题,即将占股80的京海文旅 对应到京海昌盛文化 身上。解决方式就是通过和后端童鞋沟通,让他也将每个节点对应的子节点(childrenId
)也返回过来。
新返回过来的数据结构如下:
js
let parentsData = [
(...省略...)
{
"id": "JH00003",
"parentsIdList": "JHZF00005,JHZF00006",
"name": "京海文旅投资有限公司",
"percent": "20.00",
"nodeColor": "#99ff99",
//毕竟对于父节点树,每个节点只有一个子节点,于是新添加了一个字段childrenId,用于表示当前节点对应的那个子节点是哪个。
"childrenId": "BG00001"
},
{
"id": "JH00004",
"parentsIdList": "JH00003",
"name": "京海昌盛文化股份有限公司",
"percent": "10.00",
"nodeColor": "#99ff99",
"childrenId": "BG00001"
},
{
"id": "JH00003",
"parentsIdList": "JHZF00005,JHZF00006",
"name": "京海文旅投资有限公司",
"percent": "80.00",
"nodeColor": "#99ff99",
"childrenId": "JH00004"
},
(...省略...)
]
然后,我们再在js代码中处理一下:
js
// 生成父节点树的递归函数
function generateParentsTree(target, originData) {
// 获取到父级集合
const parentsIdList = target.parentsIdList;
// 如果父级存在,则从源数据中遍历获取符合
if (parentsIdList) {
target.parents = parentsIdList.split(",").map(id => {
return originData.find(item => {
// 添加了 item.childrenId === target.id 这一段。在筛查父节点的时候必须指定父节点的子节点等于自己
return (item.id === id) && (item.childrenId === target.id)
});
})
target.parents.forEach(child => {
generateParentsTree(child, originData)
})
} else {
target.parents = null;
}
}
这样改动完毕之后,我们就能正确组装出来一个正确的树形结构了。但是,运行之后,发现节点乱飞的情况还是没有解决。
这又是为什么呢?
那是因为我们在给节点绑定数据的时候,仅仅只是将id
作为了唯一标识,如下所示
js
/*** 绘制股东树 ***/
const node2 = this.gNodes
.selectAll("g.nodeOfUpItemGroup")
.data(nodesOfUp, (d) => {
return d.data.id;
});
d3.selection.data
这个方法是为了给每一个实际的node节点分配一个数据,而这个数据需要一个唯一标识来进行区分。而我们从后端获取的数据中并没有相应的唯一标识。那么这个问题要怎么解决呢?
哈哈,其实也很简单,只需要再让后端童鞋给每个节点加上唯一标识就可以了(手动狗头)。
改动过的数据结构为:
js
let parentsData = [
(...省略...)
{
// 增加了一个字段xh,表示"序号"。以这个作为唯一标识
"xh": "4",
"id": "JH00003",
"parentsIdList": "JHZF00005,JHZF00006",
"name": "京海文旅投资有限公司",
"percent": "20.00",
"nodeColor": "#99ff99",
//毕竟对于父节点树,每个节点只有一个子节点,于是新添加了一个字段childrenId,用于表示当前节点对应的那个子节点是哪个。
"childrenId": "BG00001"
},
{
"xh": "5",
"id": "JH00004",
"parentsIdList": "JH00003",
"name": "京海昌盛文化股份有限公司",
"percent": "10.00",
"nodeColor": "#99ff99",
"childrenId": "BG00001"
},
{
"xh": "6",
"id": "JH00003",
"parentsIdList": "JHZF00005,JHZF00006",
"name": "京海文旅投资有限公司",
"percent": "80.00",
"nodeColor": "#99ff99",
"childrenId": "JH00004"
},
{
"xh": "7",
"id": "JHZF00005",
"parentsIdList": "",
"name": "京海财政厅",
"percent": "30.00",
"nodeColor": "#ffde3a",
"childrenId": "JH00003"
},
{
"xh": "8",
"id": "JHZF00006",
"parentsIdList": "",
"name": "京海国资委",
"percent": "70.00",
"nodeColor": "#ffde3a",
"childrenId": "JH00003"
},
{
"xh": "9",
"id": "JHZF00005",
"parentsIdList": "",
"name": "京海财政厅",
"percent": "30.00",
"nodeColor": "#ffde3a",
"childrenId": "JH00003"
},
{
"xh": "10",
"id": "JHZF00006",
"parentsIdList": "",
"name": "京海国资委",
"percent": "70.00",
"nodeColor": "#ffde3a",
"childrenId": "JH00003"
}
]
相应的给svg元素绑定数据的部分改为:
js
/*** 绘制股东树 ***/
const node2 = this.gNodes
.selectAll("g.nodeOfUpItemGroup")
.data(nodesOfUp, (d) => {
return d.data.xh;
});
不过,将
xh
作为唯一标识不只是改动了这块儿,具体的改动详见最后的源码
但是,当我们再次运行的时候,发现还是有问题!!
如图所示:
对比之前的版本,我们可以看到,京海文旅已经不乱飞了。这说明我们的这块儿改动是生效了,那么为什么其父节点还是乱飞呢?
其实只要我们如果看一下组装后的树形数据,就明白为什么了:
这是因为,在我们进行数据处理的时候,两个序号不同的京海文旅 的父节点却是相同的两个节点。而序号9
、序号10
的这两个节点没有被用到,按理说,组装的数据中不能含有相同的数据。那么问题出在哪儿了呢?
其实是出在了递归处理父节点的方法------generateParentsTree
中:
js
// 生成父节点树
function generateParentsTree(target, originData) {
// 获取到父级集合
const parentsIdList = target.parentsIdList;
// 如果父级存在,则从源数据中遍历获取符合
if (parentsIdList) {
target.parents = parentsIdList.split(",").map(id => {
return originData.find(item => {
return (item.id === id) && (item.childrenId === target.id)
});
})
target.parents.forEach(child => {
generateParentsTree(child, originData)
})
} else {
target.parents = null;
}
}
这里面,find
方法只会查找符合条件的第一条数据,这就导致组装数据的时候无论如何都会余下那些除了xh
之外完全相同的节点。那么怎么才能组装出正确的树状结构呢?
其实我们可以把源数据想象成一个箱子,每个节点数据就是里面的一个球。每次循环遍历的时候,都把其中的一个球从箱子中取出来,并且不再放回。这样到最后就能把所有的节点都取出来。改动后的代码如下:
js
// 生成父节点树
function generateParentsTree(target, originData) {
// 获取到父级集合
const parentsIdList = target.parentsIdList;
// 如果父级存在,则从源数据中遍历获取符合
if (parentsIdList) {
target.parents = parentsIdList.split(",").map(id => {
// 查找到符合要求的节点
let foundNode = originData.find(item => {
return (item.id === id) && (item.childrenId === target.id)
});
// 然后将这个节点从数组中删除掉,splice会改变原数组
originData.splice(originData.findIndex((d) => d === foundNode), 1)
return foundNode
})
target.parents.forEach(child => {
generateParentsTree(child, originData)
})
} else {
target.parents = null;
}
}
再看一下组装后的数据结构:
到这一步,我们才算是组装出来了一个正确的树形结构。这个时候,我们再运行一下:
这下子,总算是没有问题了。
本文主要是描述解决问题的思路,肯定不只是改动了这点地方。如果你照着文章一步一步改动,很有可能无法复现上述的效果。
并且,为了演示方便,并没有截取子节点部分,如果子节点数据中也有重复的节点数据,也需按照上述思路进行解决。
二、样式改造
其实早在之前那个版本开发完成的时候,我就想吐槽了,从美观度上来说,无论是持股比例不够直观 、节点颜色不够鲜明 、还是连线颜色和箭头颜色不统一,导致上一版怎么都说不上好看。你要说为啥,那其实是因为当时开发的时候并没有UI图,只能自己瞎琢磨样式。
但是,这次不一样了,按照UI姐姐出的设计图之后,一下子美观了不少。
不过,这个样式改动和d3
的语法就关系不大了,主要是svg
知识的运用,具体就不细说了,详情请看文章最后的源码;
三、使点击的节点居中
其实,一直以来都有一个问题困扰着我------如何才能使点击节点的时候使当前节点居中。因为,当节点一多,展开的时候往往就找不到你点击的那个节点了。会导致用户的体验很差,就像这样:
那么,有什么方法可以实现节点居中的效果呢?
1、如何实现点击节点居中
最直接简单的思路就是
- 在节点展开收缩按钮的点击事件中获取其新的坐标
- 然后将svg元素按照新的坐标进行平移
- 添加过渡动画
按照这个思路,代码如下:
js
/** 在update方法中,可以获取到新的坐标 */
update(source) {
(省略....)
// 定义一个过渡方案
const myTransition = this.svg.transition().duration(500).ease(d3.easeCubicOut)
(省略....)
let transform = d3.transition(myTransition).zoomTransform(this.gAll.node())
this.gAll.attr("transform", () => {
return `translate(${-source.x},${-source.y}) scale(${transform.k})`
})
}
其中,zoomTransform
方法,是d3
中专门用来获取当前的svg元素的缩放平移信息的,返回的数据结构是这样的,{x: number, y: number, k: number}
,其中x
h和y
表示平移距离,k
表示缩放的大小。
那么让我们看一下效果如何:
嗯~看起来效果非常不错,那算不算是大功告成了呢?
非也,其实在上图中,我只是点击了各个展开收缩按钮,并没有拖动。如果我拖动一下,就会变成这样:
如图所示,我们只是拖动了一下,直接就跳回了原点,然后再进行位移。
2、缩放器的实现原理解析
我还记得当初遇到这个问题的时候,真是百思不得其解,直接给我整懵了。
不过,后来我仔细啃了一下d3
的相关文档,终于明白是怎么回事了。
注意注意啊!!下面就是重点了!!
首先,先说一下d3
是如何实现缩放器的,如下代码所示:
js
// 绘制svg元素并为其添加zoom事件
function drawChart(){
(省略....)
// 创建svg元素,后面的代码省略
const svg = d3.create('svg')
// 在svg元素中创建一个g元素,用于整体移动整个股权穿透树
const gAll = svg.append("g").attr("id", "all");
// 添加缩放行为
svg.call(
d3.zoom()
.scaleExtent([0.01, 5])
.on("zoom", (e) => {
gAll.attr("transform", () => {
return `translate(${e.transform.x},${e.transform.y}) scale(${e.transform.k})`;
});
})
).on("dblclick.zoom", null);// 取消默认的双击放大事件
}
第一步:d3.zoom()
定义了一个transformBehavior(意为:缩放行为,这是官方的一个术语),不过只看大概率是看不懂到底是干啥的,不过不急,在后面会说清楚这玩意是干啥的。
第二步:在这个transformBehavior
上面继续添加scaleExtent
用于定义缩放的范围;
第三步:继续在其上添加zoom
事件,其回调函数中的e.transform
,和上文中的zoomTransform
是一样的,可以获取到拖动距离和缩放的大小;
第四步:再调用call
方法,将transformBehavior 赋予到svg
元素上;
transformBehavior
的本质,是定义了一个类,其上有一系列的方法,比如监听拖动,监听鼠标滚轮,监听手势触摸的。再调用了call
方法之后,这些方法的this
就指向了我们创建好的svg
元素(不过,这个svg
是d3
包装后的,类似于jquery
对象和真实dom
的关系)。然后,我们上述的一系列行为会产生一个{x: number, y: number, k: number}
结构的数据存储在其__zoom
属性中。
这就是为什么我们可以在zoom 事件中可以通过e.transform
获取到用户缩放拖动行为数据的原因。
3、解决拖动时位置错乱的问题
想必聪明的你一定搞懂了为啥会出现这个问题了:
在上述代码中,我们是直接修改的gAll
的transform
,但是并没有修改svg
元素上面存储在__zoom
中的数据。这就导致我们一拖动,就在zoom
事件中将当前__zoom
中的数据赋值到了gAll
上面。
那么,有什么办法可以修改__zoom
上面的数据吗?
其实是有的,在官网中,我们可以找到对应的修改translate
和scale
相关的API,诸如translateBy
、translateTo
、scaleBy
、scaleTo
。具体用法请参照官网。这些API都可以修改对应元素储存在__zoom
上面的真数据。
其中translateTo
最符合我们的需求,这个API意如其名,就是将translate设置到我们置顶的坐标。接下来我们舍弃上述方案,改为如下的方案:
js
drawChart(){
(省略....)
// 创建svg元素,后面的代码省略
const svg = d3.create('svg')
// 在svg元素中创建一个g元素,用于整体移动整个股权穿透树
const gAll = svg.append("g").attr("id", "all");
// 将d3.zoom()生成的zoomBehavior拆出来,然后通过下面的this.zoom = zoomBehavior使之暴露出来,可以供update方法使用;
const zoomBehavior = d3
.zoom()
.scaleExtent([0.01, 5])
.on('zoom', (e) => {
const myTransition2 = svg.transition().duration(500).ease(d3.easeCubicOut)
gAll.transition(myTransition2).attr('transform', () => {
return `translate(${e.transform.x},${e.transform.y}) scale(${e.transform.k})`
})
})
svg.call(zoomBehavior).on('dblclick.zoom', null) // 取消默认的双击放大事件
this.svg = svg;
this.zoom = zoomBehavior;
}
update(source) {
(省略....)
// 通过暴露出来的this.zoom,
this.zoom.translateTo(this.svg, source.x, source.y)
}
通过这样一番改造,我们终于解决了所有的问题。
就这样,一个高度可用的股权穿透图就完成了。
结语
自从上一个股权穿透图做完之后,我就心心念念想着等以后再迭代的时候一定要加上点击按钮居中 的功能。但是真当有时间进行迭代的时候却发现是如此艰难,不怕大家伙儿笑话,使用translateTo
重新设置位置这个思路是我憋了两天,看了许多遍官方文档才想出来的。这期间有好几次都想着要不放弃不做了。不过幸好坚持下来了。
不过,其实还差一些功能没有完成,比如节点鼠标移入弹出浮窗 、开发一个横向版本。不过这些,就留着后续再弄好了。
2024年更新任务完成了,各位,再见(疾旋鼬真可爱啊)。
源码请见:gitee.com/wushengyuan...