背景
前段时间分到了一个新需求,核心功能的ui如下(图1)
图1
其他功能都好实现,核心是底部的树结构。在和同事讨论熟悉的库和插件以后,考虑到当时感觉时间还算充裕,就决定自己先用原生canvas尝试实现。写到只剩canvas事件代理的时候,配合的后端同学来找我讨论返回数据的格式问题。这时候才发现之前想的太年轻,这部分的数据结构是图,采用的也是neo4j这种图形数据库。后端给我的数据并不是树而是节点数组和关系数组(图2),且可能会出现a —>b,b —>c,c —>a这种闭环。
_ 图2_
这里的连线就不好实现,同时考虑到明明数据就是图结构,不如换成和neo4j的图形化界面类似的关系图形式。在和组内大佬,小伙伴们讨论后,换成了echarts的关系图来做。
包发到现场给客户看了之后,客户觉得......很满意,但觉得不够规整。于是建议再 做 一 版 规 整 一 点 的,且可以切换两种状态。我特喵的......双份的快乐。
本文只记录__核心问题:计算关系图数据的坐标。不涉及业务代码
图结构转为类树结构
//转为树方法
formatData(data) {
this.nodeMap = {}; //节点放对象里方便取
let root; //中心根节点
data.allNode.forEach((item) => {
this.nodeMap[item.id] = item;
if (this.centerTableId == item.busid) {
root = item; //获取根节点
}
}); //遍历所有节点,缓存在nodeMap里
let treeNode = {}; //保存已处理节点id,防止节点重复
data.relation.forEach((item) => {
if (!treeNode[item.endNode]) { //判断是否处理过该节点
this.nodeMap[item.startNode].children =
this.nodeMap[item.startNode].children || []; //没有children则初始化children
this.nodeMap[item.startNode].children.push(
this.nodeMap[item.endNode]); //**在出发节点的children里放入目标节点**
treeNode[item.endNode] = true; //已处理过该节点
}
if (!treeNode[item.startNode]) { //判断是否处理过该节点
this.nodeMap[item.endNode].parents =
this.nodeMap[item.endNode].parents || []; //没有parents 则初始化parents
this.nodeMap[item.endNode].parents.push(this.nodeMap[item.startNode]);
//**在目标节点的parents里放入出发节点**
}
treeNode[item.startNode] = true;
}); //转为树,children为右边子节点,parents为左边父节点
return root;
}
- 放上来的代码都是我改修过几版之后的最简代码,后同
这部分的代码是我试过几个思路之后觉得最合适的方法。treeNode是重复性过滤的容器。其实children和parents里的的节点是重复,重复的原因有两个:1. 每个节点都可以通过任意节点遍历到所有节点,解决了这种结构不好找根节点的问题,任意节点都可以作为根节点。2. 作为左边树和右边树的区分。
因第二版时时间紧急,且图结构时连线情况较复杂,换掉了canvas实现。采用原生dom加上jsPlumb实现。jsPlumb为一个流程图插件,这里只用来画连线。
遍历树,获取子节点个数,以此计算父节点所占高度
// 获取元素项所占高度方法
getOtherSideHeigth(node, key) { //key为parents或者children
let sideHeight = 0;
if (node[key + 'Height']) { //已有高度时,忽略
return 0;
}
if (!node[key] || node[key].length == 0) { //没有子节点或者父节点时,所占高度为节点高度
sideHeight = 42;
node[key + 'Height'] = sideHeight;
return sideHeight;
}
let len = node[key].length;
node[key].forEach((item) => { //有子节点时先递归计算子节点高度
sideHeight += this.getSideHeigth(item, key);
});
sideHeight += (len - 1) * 50; //子节点间距高度
node[key + 'Height'] = sideHeight;
return sideHeight;
}
根据节点所占高度计算的坐标
//计算坐标
getXY(node, key) {
node.drawType = key + 'Optins'; //这里是渲染元素用的
if (!node[key]) {
return;
}
let lastY = node.y - node[key + 'Height'] / 2; //根据节点所占高度计算纵坐标
let lastX = key == 'children' ? node.x + 326 : node.x - 326; //横坐标根据固定间距计算
node[key].forEach((item) => {
item.x = lastX;
item.y = lastY + item[key + 'Height'] / 2; //根据子节点所占高度计算子节点纵坐标
lastY += item[key + 'Height'] + 50;
this.creatEle(item); //渲染元素
this.getXY(item, key);
});
}
creatEle方法省略,再用jsPlumb连线之后效果如下(图3)
_ 图3 _
你以为到这里就结束了吗?我以为是的...天真了。
目前的实现思路还是以左右两颗树拼接的形式,但是有个功能是展开上下级(图4)。
_ 图4_
如果在某个子节点展开上级之后,简单的遍历树就会出现节点未渲染,或者坐标计算错误的问题(类似图5),实际上不展开上级,数据较复杂时也可能会出现这种问题,。现有计算子节点数量然后确定节点高度的方法解决不了。因为你不知道哪个子节点会展开之后冒出来一个父节点。
_ 图5_
想解决这种情况每个节点不光要递归子节点还要递归父节点,这样计算坐标依然十分复杂。某次洗澡的时候突然想起来不知道是在学校还是在LeetCode上看到概念“桶”,给了我启发。可以把每一列看做一个桶,先把数据一个一个桶放好,然后根据桶来算坐标(类似图6)。
_ 图6_
这样的好处是逻辑和计算都简单。缺点是像是高楼大厦上的窗户,比较整齐但是没有了树的那种父子节点之间的强联系。
按桶的思路处理数据的方法
//按桶的思路处理数据
getLevel(node, level) {
if (!node || this.cacheMap[node.id]) return; //cacheMap为缓存对象,防止节点重复
this.cacheMap[node.id] = true;
this.overlaysMap[level] = this.overlaysMap[level] || [];
//初始化桶,overlaysMap为桶对象,level为桶的序号
this.overlaysMap[level].push(node); //塞桶里
let next = level + 1;
let last = level - 1;
if (node.children) {
node.children.forEach((item) => { //处理下一个桶
this.getLevel(item, next);
});
}
if (node.parents) {
node.parents.forEach((item) => { //处理上一个桶
this.getLevel(item, last);
});
}
}
按桶的思路计算坐标并渲染节点
//计算坐标并渲染节点
drawElements(root) {
let spaceY = this.option.spaceY; //节点与节点的上下间距,提出来之后修改样式只需修改配置
for (const key in this.overlaysMap) {
if (this.overlaysMap.hasOwnProperty(key)) {
const element = this.overlaysMap[key];
let len = element.length;
let startY;
let startX = root.x + this.option.spaceX * parseInt(key);
if (key != 0) { //不是根节点所在桶的计算逻辑
if (len % 2 == 0) {
startY = root.y - (len / 2 - 0.5) * (52 + spaceY);
} else {
startY = len == 1 ? root.y : root.y - (parseInt(len / 2) / 2 + 1) * (52 + spaceY);
}
element.forEach((item) => {
item.x = startX;
item.y = startY;
this.creatEle(item);
startY = startY + spaceY + 52;
});
} else { //是根节点所在桶的计算逻辑element[0]即为根节点
let helfIndex = parseInt((len - 1) / 2);
let lastY = root.y - spaceY - 52;
startY = root.y + spaceY + 52;
for (let i = helfIndex; i >= 1; i--) {
element[i].x = root.x;
element[i].y = lastY;
this.creatEle(element[i]);
lastY = lastY - spaceY - 52;
}
for (let i = helfIndex + 1; i < len; i++) {
element[i].x = root.x;
element[i].y = startY;
this.creatEle(element[i]);
startY = startY + spaceY + 52;
}
}
}
}
}
完成效果如下(图7):
完成的效果虽然没有了树的父子强关系效果,但是不会有之前按树处理时,有些节点未渲染,有些节点坐标错误的问题,毕竟数据原本的结构是图。最后上一下价值<- _ <-,很多时候想一次实现需求,逻辑会写的非常复杂,我认为最好的方式还是寻求最简解决方法,越复杂越容易在某个环节出问题,或者说必然会出问题。第一次写这块的时候,甚至想把用户选择的下游级别的节点也完全放在右边,上游的完全放在左边。这不光在节点重复时有问题,还导致刚开始无法解决节点闭环问题。后续越改越“简单”(逻辑上),写的更清晰也不容易出bug。