开发思路简记-数据血缘(图结构转为类树结构)

2,308 阅读6分钟

背景

前段时间分到了一个新需求,核心功能的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。