d3.js开发股权穿透图分享

2,882 阅读7分钟

分享大纲

  1. 股权图展示
  2. 需求描述
  3. 遇到的问题与解决办法
    • 子节点动态插入与删除
    • 根节点不能展开收起操作
    • 缩放拖拽抖动问题
    • 图片下载不全
    • 下载图片的连接线过粗
    • 全屏与退出全屏
    • 连接线被遮挡问题(曲线替换为直线)
  4. 从0开发一个股权穿透图

一、股权图展示

最终效果图 image.png

初版设计图

image.png

二、需求描述

  • 参考爱企查绘制股权穿透图,支持多公司同时查询(目前支持2家公司)。
  • 相关联的公司展示关键关系图全部关系图(可选)。
  • 公司可多次出现,但不能跨级连接。
  • 默认展示2级数据,如遇根节点(可展开),就多展示一层
  • 子节点支持展开收起。
  • 用不同颜色框标识出公司与个人,及展示公司所在地。
  • 支持图片缩放;全屏/退出全屏;图片下载;重置功能。 image.png

三、遇到的问题与解决办法

1. 子节点动态插入与删除

oldTreeItem: 处理前(当前)的全部节点数据。

 click(source, showType) {
   // 收缩
   if (source?.children?.length) {
     showType === "down"
       ? this.setNewData(this.oldTreeItem, source.nodeId, [])
       : this.setNewDataUp(this.oldTreeItem, source.nodeId, []);
     source.children = [];
     setTimeout(() => {
       this.init2(this.oldTreeItem);
     }, 0);
   } else {
     // 展开
     if (!source.data.children.length && source.data.investment) {
       const data = {
         id: source.data.id, //节点id
         nodeType: showType === "up" ? 1 : 2, //获取节点id的父节点还是子节点 1 父节点 2 子节点
       };
       queryNode(data).then((res) => {
         showType === "down"
           ? this.setNewData(this.oldTreeItem, source.nodeId, res.data)
           : this.setNewDataUp(this.oldTreeItem, source.nodeId, res.data);
         source.children = res.data;
         setTimeout(() => {
           this.init2(this.oldTreeItem);
         }, 0);
       });
     }
 },

 setNewData(oldTree, nodeId, data) {
   if (!(oldTree.children instanceof Array)) return null;
   for (let i of oldTree.children) {
     if (i.nodeId === nodeId) {
       i.children = JSON.parse(JSON.stringify(data));
       return;
     } else {
       this.setNewData(i, nodeId, data);
     }
   }
 },

 setNewDataUp(oldTree, nodeId, data) {
   if (oldTree.nodeId === nodeId) {
     oldTree.parents = JSON.parse(JSON.stringify(data));
   } else {
     let temptree = oldTree?.parents?.length
       ? oldTree.parents
       : oldTree.children;
     for (let i of temptree) {
       if (i.nodeId === nodeId) {
         i.children = JSON.parse(JSON.stringify(data));
         return;
       } else {
         this.setNewDataUp(i, nodeId, data);
       }
     }
   }
 }

2. 根节点不能展开收起操作

  • d3.hierarchy – 从给定的层次结构数据构造一个根节点并为各个节点指定深度等属性.

  • treeItem 数据举例

    image.png

  • rootUp,rootDown 数据举例

image.png

image.png

初始化的时候,将全部数据分为up , down两个类型,在最终渲染后,根节点的type就是down

所以无法区分根节点类型,添加不了扩展符号。

 init2(treeItem) {
   this.oldTreeItem = JSON.parse(JSON.stringify(treeItem));
   let upTree = null;
   let downTree = null;
   // 拷贝树的数据
   Object.keys(treeItem).map((item) => {
     if (item === "parents") {
       upTree = JSON.parse(JSON.stringify(treeItem));
       upTree.children = treeItem[item];
       upTree.parents = null;
     } else if (item === "children") {
       downTree = JSON.parse(JSON.stringify(treeItem));
       downTree.children = treeItem[item];
       downTree.parents = null;
     }
   });
   
   // hierarchy 返回新的结构 x0,y0初始化起点坐标
   this.rootUp = d3.hierarchy(upTree, (d) => d.children;);
   this.rootUp.x0 = 0;
   this.rootUp.y0 = 0;

   this.rootDown = d3.hierarchy(downTree, (d) => d.children);
   this.rootDown.x0 = 0;
   this.rootDown.y0 = 0;
   
   // 上 和 下 结构
   let treeArr = [
     {
       data: this.rootUp,
       type: "up",
     },
     {
       data: this.rootDown,
       type: "down",
     },
   ];
   treeArr.map((item) => {
      // 控制展示根节点上下两层数据
     item.data.data.children.forEach(this.collapse);
      // 渲染
     this.update(item.data, item.type, item.data);
   });
 },

结论:遇到根节点,如果有可扩展值就默认多展示一层,不去操作这个特殊的根节点。

3. 缩放拖拽抖动问题

根本原因是原来使用的方法在缩放时去记录了原始位置,对原位置进行操作,但拖拽时图片会从当前画布的(0,0)坐标开始滑动,视觉上给人一种抖动的感觉。

 // 缩放监听(写在svg初始化方法中)
  this.zoom = d3.zoom()
     .scaleExtent([1, 3]) 
     .on("zoom", (e) => {
       this.scaleData = d3.event.transform.k; // 缩放值
       this.svg.attr(
         "transform",
         d3.event.transform.translate(svgW / 2, svgH / 2)
       );
     });
   this.svg.call(this.zoom);

 // 核心代码
 setRange(type) {
   this.zoom.scaleBy(this.svg, type === "big" ? 1.2 : 0.8);
 },

存在的问题:当缩放范围最小值小于1时,拖拽图片图片会在原地轻微抖动

解决方法:缩放最小值设置为1。

4. 图片下载不全

  • zoom有缩放移动功能,导致下载的图片有2种情况:

    • 下载当前视图的svg(只下载页面呈现部分)
    • 无视缩放,移动位置,下载整个svg

    缩放问题引起的缩放值取值不正确,导致下载图片,等比缩放时比例错误,下载图片不全。

参数zoomClassName: 元素g , 包含可缩放移动的数据。

image.png

   svgDownloadAll(svg, zoomClassName) {
   //得到svg的真实大小
   var box = svg.getBBox(),
     x = box.x,
     y = box.y,
     width = box.width,
     height = box.height;
     
   if (zoomClassName) {
     //查找zoomObj
     var zoomObj = svg.getElementById(zoomClassName.replace(/\./g, ""));
     if (!zoomObj) {
       alert("zoomObj不存在");
       return false;
     }
     /*------这里是处理svg缩放的--------*/
     var transformMath = zoomObj.getAttribute("transform"),
       scaleMath = zoomObj.getAttribute("transform");
     if (transformMath || scaleMath) {
       var transformObj = transformMath.match(
         /translate\(([^,]*),([^,)]*)\)/
       );
       if (transformObj) {
         // 原缩放,移动值 反应用到svg的宽高上
         var translateX = transformObj[1],
           translateY = transformObj[2],
           scale = this.scaleData;
         x = (x - translateX) / scale;
         y = (y - translateY) / scale;
         width = width / Number(scale);
         height = height / Number(scale);
       }
     }
   }
   //克隆svg
   var node = svg.cloneNode(true);
   
   //重新设置svg的width,height,viewbox
   node.setAttribute("width", width);
   node.setAttribute("height", height);
   node.setAttribute("viewBox", [x, y, width, height]);
   
   if (zoomClassName) {
     var zoomObj1 = node.getElementById(zoomClassName.replace(/\./g, ""));
     /*-------------清除缩放元素的缩放-------------*/
     zoomObj1.setAttribute("transform", "translate(0,0) scale(1)");
   }
   this.downloadSvgFn(node);
 },

下载流程:

  • 将svg转成字符串 点击查看

  • 转换之后,将svg字符串变成image的src

  • 用canvas绘制image

  • 将dataurl 转成 blob

  • 模拟点击事件,下载blob

 downloadSvgFn(svg) {
   let that = this;
   var serializer = new XMLSerializer();
   var source =
     '<?xml version="1.0" standalone="no"?>\r\n' +
     serializer.serializeToString(svg);
   var image = new Image();
   image.src =
     "data:image/svg+xml;charset=utf-8," + encodeURIComponent(source);
   image.onload = function () {
     var width = this.naturalWidth,
       height = this.naturalHeight;
     var canvas = document.createElement("canvas");
     canvas.width = width;
     canvas.height = height;
     var context = canvas.getContext("2d");
     context.rect(0, 0, width, height);
     context.fillStyle = "#fff";
     context.fill();
     context.drawImage(image, 0, 0);
     var imgSrc = canvas.toDataURL("image/jpg", 1);
     that.base64DownloadFile(imgSrc);
   };
 },
 base64DownloadFile(content) {
   let aLink = document.createElement("a");
   let blob = this.base64ToBlob(content); //new Blob([content]);
   let evt = document.createEvent("HTMLEvents");
   evt.initEvent("click", true, true); //initEvent 不加后两个参数在FF下会报错  事件类型,是否冒泡,是否阻止浏览器的默认行为
   aLink.download = "股权架构";
   aLink.href = URL.createObjectURL(blob);
   aLink.click();
 },
 base64ToBlob(code) {
   let parts = code.split(";base64,");
   let contentType = parts[0].split(":")[1];
   let raw = window.atob(parts[1]);
   let rawLength = raw.length;
   let uInt8Array = new Uint8Array(rawLength);
   for (let i = 0; i < rawLength; ++i) {
     uInt8Array[i] = raw.charCodeAt(i);
   }
   return new Blob([uInt8Array], { type: contentType });
 },

5. 下载图片的连接线过粗

image.png

image.png 设置一下fill-opacity线条透明度即可。

6. 全屏与退出全屏

   handleFullScreen() {
    let element = document.documentElement;
    // 判断是否已经是全屏
    // 如果是全屏,退出
    if (this.fullscreen) {
      if (document.exitFullscreen) {
        document.exitFullscreen();
      } else if (document.webkitCancelFullScreen) {
        document.webkitCancelFullScreen();
      } else if (document.mozCancelFullScreen) {
        document.mozCancelFullScreen();
      } else if (document.msExitFullscreen) {
        document.msExitFullscreen();
      }
    } else {
      // 否则,进入全屏
      if (element.requestFullscreen) {
        element.requestFullscreen();
      } else if (element.webkitRequestFullScreen) {
        element.webkitRequestFullScreen();
      } else if (element.mozRequestFullScreen) {
        element.mozRequestFullScreen();
      } else if (element.msRequestFullscreen) {
        // IE11
        element.msRequestFullscreen();
      }
    }
    this.fullscreen = !this.fullscreen;
  },

7. 连接线被遮挡问题(曲线替换为直线)

最开始使用的是贝塞尔曲线,因为随着层级加深,倾斜角度也越来越高,线条会被矩形模块遮挡,所以替换了直线展示。

曲线

  • M x y : 移动到指定坐标,x,y分别为轴坐标点,即为起点
  • C x1 y1 x2 y2 x y : 三次贝塞尔曲线
    • 当前点为起点,xy为终点,起点和x1y1控制曲线起始的斜率,终点和x2y2控制结束的斜率。
 diagonal (s, d, showtype) {
    let path
    if (showtype === 'up') {
      path = `M ${s.x} ${-s.y + 24}
    C${s.x} -${(s.y + d.y) * 0.45},
     ${s.x} -${(s.y + d.y) * 0.45},
      ${d.x} -${d.y}`;
    } else {
      path = `M ${s.x} ${s.y}
    C${s.x} ${(s.y + d.y) * 0.45},
     ${s.x} ${(s.y + d.y) * 0.45},
      ${d.x} ${d.y}`;
    }
    return path;
  },

直线

  • L x y :在初始位置(M 画的起点)和xy确定的坐标画一条线。
 diagonal(s, d, showtype) {
      let path;
      const halfDistance = (d.y - s.y) / 2;
      const halfY = s.y + halfDistance;
      if (showtype === "up") {
        path = `M${s.x} ${-s.y + 30}
        L${s.x},${-halfY} L${d.x},${-halfY} L${d.x}, ${-d.y}`;
      } else {
        path = `M${s.x} ${s.y}
        L${s.x},${halfY} L${d.x},${halfY} L${d.x},${d.y}`;
      }
      return path;
    },

svg path路径指令

8. 弹窗展示公司信息

点击公司,展示公司详情。

核心知识点:svgRect.getBoundingClientRect()

image.png

image.png

 showDialog(d) {
      if (d.data.subjectType === 1 || d.data.subjectType === 3) {
        const FormDatas = new FormData();
        FormDatas.append("id", d.data.id);
        this.$axios({
          url: "/equityrelation/queryCompanyInfoByNodeId",
          method: "post",
          data: FormDatas,
        }).then((res) => {
          if (res.data.code === 200) {
            const svgRect = document.querySelector(`rect[type="${d.nodeId}"]`);
            var rt = svgRect.getBoundingClientRect();
            document
              .getElementById("dialog")
              .setAttribute(
                "style",
                `display:block;top:${rt.bottom}px;left:${rt.right}px`
              );
          
            const {
              companyName,
              companyZhName,
              companyRegNo,
              registerDate,
              registerAmount,
              registerCurrencyCode,
              id,
              auth,
            } = res.data.data;
            this.dialogData = {
              id,
              auth,
              companyName,
              companyZhName,
              companyRegNo,
              registerDate,
              registerAmount,
              registerCurrencyCode,
            };
          } else {
            this.$message.error(res.data.message);
          }
        });
      }
    },

四、从0开发一个股权穿透图

举例的代码是来自 d3 股权穿透图干货这篇内容,运行可直接看效果,写的很好。

相关知识点:

<template>
    <div class="penetrate-chart">
        <div class="bt-group">
            <button class="reset" @click="resetSvg">重置</button>
        </div>
    </div>
</template>

<script>
  // 过渡时间
  const DURATION = 0
  // 加减符号半径
  const SYMBOLA_S_R = 9
  // 公司
  const COMPANY = 0
  // 人
  const PERSON = 1
  export default {
    props: {},

    components: {},

    data () {
      return {
        layoutTree: '',
        diamonds: '',
        d3: this.$d3,
        i: 0,
        hasChildNodeArr: [],
        originDiamonds: '',
        diagonalUp: '',
        diagonalDown: '',
        tree: {"name":"多多包","children":[{"name":"一卡通公司","type":0},{"name":"一卡通公司2","type":0,"children":[{"name":"小公司","type":0,"children":[{"name":"小小小","type":0,"children":[{"type":1,"name":"笑小下"}]}]},{"type":0,"name":"小公司2"}]},{"name":"一卡通公司2333","type":0,"children":[{"type":0,"name":"小公司"},{"type":0,"name":"小公司2"}]},{"type":0,"name":"一卡通公司2222"}],"parents":[{"name":"大公司","type":0,"children":[{"name":"发发委","type":0,"money":"780万元","children":[{"type":0,"money":"780万元","name":"123"}]},{"name":"123发发委","money":"780万元","type":0,"children":[{"money":"780万元","type":0,"name":"123"}]}]},{"name":"多多网","money":"780万元","type":0,"children":[{"type":0,"money":"780万元","name":"发哈哈"}]},{"name":"龙龙投资","money":"780万元","type":0,"children":[{"type":1,"money":"780万元","name":"王林"},{"type":1,"money":"780万元","name":"张峰"},{"type":1,"money":"780万元","name":"侯明"}]}]},
        rootUp: '',
        rootDown: '',
        svg: ''
      }
    },

    mounted () {
      this.init()
    },

    methods: {
      init () {
        let d3 = this.d3
        let svgW = document.body.clientWidth
        let svgH = 500
        // 方块形状
        this.diamonds = {
          w: 145,
          h: 68,
          intervalW: 200,
          intervalH: 150
        }
        // 源头对象
        this.originDiamonds = {
          w: 190
        }
        this.layoutTree = d3.tree().nodeSize([this.diamonds.intervalW, this.diamonds.intervalH]).separation(() => 1);
        // 主图
        this.svg = d3.select('#app').append('svg').attr('width', svgW).attr('height', svgH).attr('id', 'treesvg')
          .call(d3.zoom().scaleExtent([0, 5]).on('zoom', () => {
            // 设置缩放位置以及平移初始位置
            this.svg.attr('transform', d3.event.transform.translate(svgW / 2, svgH / 2));
          }))
          .attr('style', 'position: relative;z-index: 2;')
          .append('g').attr('id', 'g').attr('transform', 'translate(' + (svgW / 2) + ',' + (svgH / 2) + ')');
        let upTree = null
        let downTree = null
 // 拷贝树的数据
        Object.keys(this.tree).map(item => {
          if (item === 'parents') {
            upTree = JSON.parse(JSON.stringify(this.tree))
            upTree.children = this.tree[item]
            upTree.parents = null
 } else if (item === 'children') {
            downTree = JSON.parse(JSON.stringify(this.tree))
            downTree.children = this.tree[item]
            downTree.parents = null
 }
        })
        // hierarchy 返回新的结构 x0,y0初始化起点坐标
        this.rootUp = d3.hierarchy(upTree, d => d.children);
        this.rootUp.x0 = 0
        this.rootUp.y0 = 0

        this.rootDown = d3.hierarchy(downTree, d => d.children);
        this.rootDown.x0 = 0
        this.rootDown.y0 = 0;
        // 上 和 下 结构
        let treeArr = [
          {
            data: this.rootUp,
            type: 'up'
          },
          {
            data: this.rootDown,
            type: 'down'
          }
        ]
        treeArr.map(item => {
          item.data.children.forEach(this.collapse);
          this.update(item.data, item.type, item.data)
        })
      },

      /*
       *[update 函数描述], [click 函数描述]
       *  @param  {[Object]} source 第一次是初始源对象,后面是点击的对象
       *  @param  {[String]} showtype up表示向上 down表示向下
       *  @param  {[Object]} sourceTree 初始源对象
       */
      update (source, showtype, sourceTree) {
        let _this = this
        if (source.parents === null) {
          source.isOpen = !source.isOpen
        }
        let nodes
        if (showtype === 'up') {
          nodes = this.layoutTree(this.rootUp).descendants()
        } else {
          nodes = this.layoutTree(this.rootDown).descendants()
        }
        let links = nodes.slice(1);
        nodes.forEach(d => {
          d.y = d.depth * this.diamonds.intervalH;
        });

        let node = this.svg.selectAll('g.node' + showtype)
          .data(nodes, d => d.id || (d.id = showtype + ++this.i));

        let nodeEnter = node.enter().append('g')
          .attr('class', d => showtype === 'up' && !d.depth ? 'hide-node' : 'node' + showtype)
          .attr('transform', d => showtype === 'up' ? 'translate(' + d.x + ',' + -(d.y) + ')' : 'translate(' + d.x + ',' + d.y + ')')

        // 创建矩形
        nodeEnter.append('rect')
          .attr('type', d => d.id)
          .attr('width', d => d.depth ? this.diamonds.w : this.originDiamonds.w)
          .attr('height', d => d.depth ? (d.type === COMPANY ? this.diamonds.h : this.diamonds.h - 10) : 30)
          .attr('x', d => d.depth ? -this.diamonds.w / 2 : -this.originDiamonds.w / 2)
          .attr('y', d => d.depth ? showtype === 'up' ? -this.diamonds.h / 2 : 0 : -15)
          .attr('stroke', d => d.data.type === COMPANY || !d.depth ? '#FD7D00' : '#7A9EFF')
          .attr('stroke-width', 1)
          .attr('rx', 5)
          .attr('ry', 5)
          .style('fill', d => {
            if (d.data.type === COMPANY || !d.depth) {
              return d._children ? '#FFF1D7' : (d.depth ? '#fff' : '#FD7D00')
            } else if (d.data.type === PERSON) {
              return d._children ? '#fff' : (d.depth ? '#fff' : '#7A9EFF')
            }
          });

        // 创建圆 加减
        nodeEnter.append('circle')
          .attr('type', d => d.id || (d.id = showtype + 'text' + ++this.i))
          .attr('r', (d) => d.depth ? (this.hasChildNodeArr.indexOf(d) === -1 ? 0 : SYMBOLA_S_R) : 0)
          .attr('cy', d => d.depth ? showtype === 'up' ? -(SYMBOLA_S_R + this.diamonds.h / 2) : this.diamonds.h : 0)
          .attr('cx', 0)
          .attr('fill', d => d.children ? '#fff' : '#FD7D00')
          .attr('stroke', d => d._children || d.children ? '#FD7D00' : '')
          .on('click', function (d) {
            _this.click(d, showtype, sourceTree)
            setTimeout(() => {
              if (document.querySelector(`text[type="${d.id}"]`).innerHTML === '-') {
                d.isOpen = false
                this.innerHTML = '+'
                this.setAttribute('fill', '#FD7D00')
                document.querySelector(`text[type="${d.id}"]`).setAttribute('fill', '#fff')
                document.querySelector(`rect[type="${d.id}"]`).setAttribute('style', 'fill:#FFF1D7')
                document.querySelector(`text[type="${d.id}"]`).innerHTML = '+'
              } else {
                d.isOpen = true
                this.setAttribute('fill', '#fff')
                document.querySelector(`text[type="${d.id}"]`).setAttribute('fill', '#FD7D00')
                document.querySelector(`rect[type="${d.id}"]`).setAttribute('style', 'fill:#fff')
                document.querySelector(`text[type="${d.id}"]`).innerHTML = '-'
              }
            }, DURATION)
          });

        // 持股比例
        nodeEnter.append('g')
          .attr('transform', () => 'translate(0,0)')
          .append('text')
          .attr('class', d => !d.depth ? 'proportion-hide' : 'proportion')
          .attr('x', d => d.x > 0 ? (showtype === 'up' ? -30 : 30) : 30)
          .attr('y', showtype === 'up' ? this.diamonds.h : -20)
          .attr('text-anchor', 'middle')
          .attr('fill', d => d.data.type === COMPANY ? '#FD7D00' : '#7A9EFF')
          .text(d => '30%');

        // 公司名称
        // y轴 否表源头的字体距离
        nodeEnter.append('text')
          .attr('class', 'text-style-name')
          .attr('x', 0)
          .attr('y', showtype === 'up' ? -this.diamonds.h / 2 : 0)
          .attr('dy', d => d.depth ? (d.data.name.length > 9 ? '1.5em' : '2em') : '.3em')
          .attr('text-anchor', 'middle')
          .attr('fill', d => d.depth ? '#465166' : '#fff')
          .text(d => (d.data.name.length > 9) ? d.data.name.substr(0, 9) : d.data.name);

        // 名称过长 第二段
        nodeEnter.append('text')
          .attr('class', 'text-style-name')
          .attr('x', 0)
          .attr('y', showtype === 'up' ? -this.diamonds.h / 2 : 0)
          .attr('dy', d => d.depth ? '3em' : '.3em')
          .attr('text-anchor', 'middle')
          .attr('fill', d => d.depth ? '#465166' : '#fff')
          .text(d => d.data.name.substr(9, d.data.name.length));

        // 认缴金额
        nodeEnter.append('text')
          .attr('class', 'text-style-money')
          .attr('x', 0)
          .attr('y', showtype === 'up' ? -this.diamonds.h / 2 : 0)
          .attr('dy', d => d.data.name.substr(9, d.data.name.length).length ? '5em' : '4em')
          .attr('text-anchor', 'middle')
          .attr('fill', d => d.depth ? '#465166' : '#fff')
          .text(d => d.data.money);
        /*
        * 绘制箭头
        * @param  {string} markerUnits [设置为strokeWidth箭头会随着线的粗细发生变化]
        * @param {string} viewBox 坐标系的区域
        * @param {number} markerWidth,markerHeight 标识的大小
        * @param {string} orient 绘制方向,可设定为:auto(自动确认方向)和 角度值
        * @param {number} stroke-width 箭头宽度
        * @param {string} d 箭头的路径
        * @param {string} fill 箭头颜色
        * @param {string} id resolved0表示公司 resolved1表示个人
        * 直接用一个marker达不到两种颜色都展示的效果
        */
        nodeEnter.append('marker')
          .attr('id', showtype + 'resolved0')
          .attr('markerUnits', 'strokeWidth')
          .attr('markerUnits', 'userSpaceOnUse')
          .attr('viewBox', '0 -5 10 10')
          .attr('markerWidth', 12)
          .attr('markerHeight', 12)
          .attr('orient', '90')
          .attr('refX', () => showtype === 'up' ? '-5' : '15')
          .attr('stroke-width', 2)
          .attr('fill', 'red')
          .append('path')
          .attr('d', 'M0,-5L10,0L0,5')
          .attr('fill', '#FD7D00');

        nodeEnter.append('marker')
          .attr('id', showtype + 'resolved1')
          .attr('markerUnits', 'strokeWidth')
          .attr('markerUnits', 'userSpaceOnUse')
          .attr('viewBox', '0 -5 10 10')
          .attr('markerWidth', 12)
          .attr('markerHeight', 12)
          .attr('orient', '90')
          .attr('refX', () => showtype === 'up' ? '-5' : '15')
          .attr('stroke-width', 2)
          .attr('fill', 'red')
          .append('path')
          .attr('d', 'M0,-5L10,0L0,5')
          .attr('fill', '#7A9EFF');

        // 将节点转换到它们的新位置。
        let nodeUpdate = node.transition()
          .duration(DURATION)
          .attr('transform', d => showtype === 'up' ? 'translate(' + d.x + ',' + -(d.y) + ')' : 'translate(' + d.x + ',' + (d.y) + ')');

        // 代表是否展开的+-号,function this指向当前dom
        nodeEnter.append('svg:text')
          .attr('type', d => d.id || (d.id = showtype + 'text' + ++this.i))
          .on('click', function (d) {
            _this.click(d, showtype, sourceTree)
            setTimeout(() => {
              if (this.innerHTML === '-') {
                d.isOpen = false
                this.innerHTML = '+'
                this.setAttribute('fill', '#fff')
                document.querySelector(`circle[type="${d.id}"]`).setAttribute('fill', '#FD7D00')
                document.querySelector(`rect[type="${d.id}"]`).setAttribute('style', 'fill:#FFF1D7')
              } else {
                d.isOpen = true
                this.innerHTML = '-'
                this.setAttribute('fill', '#FD7D00')
                document.querySelector(`circle[type="${d.id}"]`).setAttribute('fill', '#fff')
                document.querySelector(`rect[type="${d.id}"]`).setAttribute('style', 'fill:#fff')
              }
            }, DURATION)
          })
          .attr('x', 0)
          .attr('dy', d => d.depth ? (showtype === 'up' ? -(SYMBOLA_S_R / 2 + this.diamonds.h / 2) : this.diamonds.h + 4) : 0)
          .attr('text-anchor', 'middle')
          .attr('fill', d => d._children ? '#fff' : '#FD7D00')
          .text(d => this.hasChildNodeArr.indexOf(d) !== -1 ? (source.depth && d.isOpen ? '-' : '+') : '');

        // 将退出节点转换到父节点的新位置.
        let nodeExit = node.exit().transition()
          .duration(DURATION)
          .attr('transform', () => showtype === 'up' ? 'translate(' + source.x + ',' + -(source.y) + ')' : 'translate(' + source.x + ',' + (parseInt(source.y)) + ')')
          .remove();

        nodeExit.select('rect')
          .attr('width', this.diamonds.w)
          .attr('height', this.diamonds.h)
          .attr('stroke', 'black')
          .attr('stroke-width', 1);

        // 修改线条
        let link = this.svg.selectAll('path.link' + showtype)
          .data(links, d => d.id);

        // 在父级前的位置画线。
        let linkEnter = link.enter().insert('path', 'g')
          .attr('class', 'link' + showtype)
          .attr('marker-start', d => `url(#${showtype}resolved${d.data.type})`)// 根据箭头标记的id号标记箭头
          .attr('stroke', d => d.data.type === COMPANY ? '#FD7D00' : '#7A9EFF')
          .style('fill-opacity', 1)
          .attr('d', () => {
            let o = {x: source.x0, y: source.y0};
            return _this.diagonal(o, o, showtype)
          });

        let linkUpdate = linkEnter.merge(link);
        // 过渡更新位置.
        linkUpdate.transition()
          .duration(DURATION)
          .attr('d', d => _this.diagonal(d, d.parent, showtype));

        // 将退出节点转换到父节点的新位置
        link.exit().transition()
          .duration(DURATION)
          .attr('d', () => {
            let o = {
              x: source.x,
              y: source.y
            };
            return _this.diagonal(o, o, showtype)
          }).remove();

        // 隐藏旧位置方面过渡.
        nodes.forEach(d => { d.x0 = d.x; d.y0 = d.y });
      },

      // 拷贝到_children 隐藏1排以后的树
      collapse (source) {
        if (source.children) {
          source._children = source.children;
          source._children.forEach(this.collapse);
          source.children = null;
          this.hasChildNodeArr.push(source);
        }
      },

      click  (source, showType, sourceTree) {
        // 不是起点才能点
        if (source.depth) {
          if (source.children) {
            source._children = source.children;
            source.children = null;
          } else {
            source.children = source._children;
            source._children = null;
          }
          this.update(source, showType, sourceTree)
        }
      },

      diagonal (s, d, showtype) {
        let path
        if (showtype === 'up') {
          path = `M ${s.x} ${-s.y + 24}
        C${s.x} -${(s.y + d.y) * 0.45},
         ${s.x} -${(s.y + d.y) * 0.45},
          ${d.x} -${d.y}`;
        } else {
          path = `M ${s.x} ${s.y}
        C${s.x} ${(s.y + d.y) * 0.45},
         ${s.x} ${(s.y + d.y) * 0.45},
          ${d.x} ${d.y}`;
        }
        return path;
      },

      resetSvg () {
        this.d3.select('#treesvg').remove()
        this.init()
      }
    }

  }
</script>

<style lang="scss">
    .penetrate-chart {
        .bt-group{
            position: fixed;
            z-index: 999;
            right: 15px;
            bottom: 15px;
            button{
                width:88px;
                height:32px;
                display: block;
                border-radius:18px;
                font-size:14px;
                font-family:PingFangSC-Medium;
                font-weight:500;
                line-height:20px;
            }
            .save{
                background:rgba(255,168,9,1);
                color:rgba(255,255,255,1);
            }
            .reset{
                margin-top: 8px;
                color: rgba(255, 168, 9, 1);
                background: white;
                border:1px solid rgba(255,168,9,1);
            }
        }
    }
    #treesvg{
        display: block;
        margin: auto;
        #g{
            .linkup, .linkdown {
                fill: none;
                stroke-width: 1px;
            }
            .text-style-name{
                font-size:12px; /*no*/
 font-family:PingFangSC-Medium;
                font-weight:500;
            }
            .text-style-money{
                font-size:10px; /*no*/
 font-family:PingFangSC-Regular;
                font-weight:400;
                color:rgba(70,81,102,1)
            }
            .proportion{
                font-size:10px;
                font-family:PingFangSC-Regular;
                font-weight:400;
            }
        }
        .proportion-hide, .hide-node{
            display: none;
        }
    }
</style>

参考文档: d3.js官网 d3 股权穿透图干货 d3.js中svg的下载5分钟看懂svg path路径的所有命令