d3.js实现股权穿透图(vue+d3.js)

业务需求

前段时间写过一篇使用relation-graph插件实现的股权穿透图效果(relation-graph实现股权穿透图),但是目前公司有了新需求,想要连接线上携带股权百分比。之前的插件兼容性有点小问题,所以只能换个思路去实现(d3.js+vue)。

1、实现的效果图

在这里插入图片描述

2、安装依赖

npm install d3 --save-dev

3、全部代码(复制粘贴即可实现)

<template>
  <div id="appc" style="height: 650px"></div>
</template>
<script>
import * as $d3 from "d3";
export default {
  name: "legalperson",
  data() {
    return {
      d3: $d3,
      data: {
        id: "abc1005",
        // 根节点名称
        name: "山东吠舍科技有限责任公司",
        // 子节点列表
        children: [
          {
            id: "abc1006",
            name: "山东第一首陀罗科技服务有限公司",
            percent: "100%",
          },
          {
            id: "abc1007",
            name: "山东第二首陀罗程技术有限公司",
            percent: "100%",
          },
          {
            id: "abc1008",
            name: "山东第三首陀罗光伏材料有限公司",
            percent: "100%",
          },
          {
            id: "abc1009",
            name: "山东第四首陀罗科技发展有限公司",
            percent: "100%",
            children: [
              {
                id: "abc1010",
                name: "山东第一达利特瑞利分析仪器有限公司",
                percent: "100%",
                children: [
                  {
                    id: "abc1011",
                    name: "山东瑞利的子公司一",
                    percent: "80%",
                  },
                  {
                    id: "abc1012",
                    name: "山东瑞利的子公司二",
                    percent: "90%",
                  },
                  {
                    id: "abc1013",
                    name: "山东瑞利的子公司三",
                    percent: "100%",
                  },
                ],
              },
            ],
          },
          {
            id: "abc1014",
            name: "山东第五首陀罗电工科技有限公司",
            percent: "100%",
            children: [
              {
                id: "abc1015",
                name: "山东第二达利特低自动化设备有限公司",
                percent: "100%",
                children: [
                  {
                    id: "abc1016",
                    name: "山东敬业的子公司一",
                    percent: "100%",
                  },
                  {
                    id: "abc1017",
                    name: "山东敬业的子公司二",
                    percent: "90%",
                  },
                ],
              },
            ],
          },
          {
            id: "abc1020",
            name: "山东第六首陀罗分析仪器(集团)有限责任公司",
            percent: "100%",
            children: [
              {
                id: "abc1021",
                name: "山东第三达利特分气体工业有限公司",
              },
            ],
          },
        ],
        // 父节点列表
        parents: [
          {
            id: "abc2001",
            name: "山东刹帝利集团有限责任公司",
            percent: "60%",
            parents: [
              {
                id: "abc2000",
                name: "山东婆罗门集团有限公司",
                percent: "100%",
              },
            ],
          },
          {
            id: "abc2002",
            name: "吴小远",
            percent: "40%",
            parents: [
              {
                id: "abc1010",
                name: "山东第一达利特瑞利分析仪器有限公司",
                percent: "100%",
                parents: [
                  {
                    id: "abc1011",
                    name: "山东瑞利的子公司一",
                    percent: "80%",
                  },
                  {
                    id: "abc1012",
                    name: "山东瑞利的子公司二",
                    percent: "90%",
                  },
                  {
                    id: "abc1013",
                    name: "山东瑞利的子公司三",
                    percent: "100%",
                  },
                ],
              },
            ],
          },
          {
            id: "abc2003",
            name: "测试数据",
            percent: "40%",
          },
        ],
      },
    };
  },
  mounted() {
    this.constructor();
  },

  methods: {
    // 股权树
    constructor(options) {
      // 树的源数据
      this.originTreeData = this.data;
      // 宿主元素选择器
      this.el = document.getElementById("appc");
      // 一些配置项
      this.config = {
        // 节点的横向距离
        dx: 200,
        // 节点的纵向距离
        dy: 170,
        // svg的viewBox的宽度
        width: 0,
        // svg的viewBox的高度
        height: 500,
        // 节点的矩形框宽度
        rectWidth: 170,
        // 节点的矩形框高度
        rectHeight: 70,
      };
      this.svg = null;
      this.gAll = null;
      this.gLinks = null;
      this.gNodes = null;
      // 给树加坐标点的方法
      this.tree = null;
      // 投资公司树的根节点
      this.rootOfDown = null;
      // 股东树的根节点
      this.rootOfUp = null;
      this.drawChart({
        type: "fold",
      });
    },

    // 初始化树结构数据
    drawChart(options) {
      // 宿主元素的d3选择器对象
      let host = this.d3.select(this.el);
      // 宿主元素的DOM,通过node()获取到其DOM元素对象
      let dom = host.node();
      // 宿主元素的DOMRect
      let domRect = dom.getBoundingClientRect();
      // svg的宽度和高度
      this.config.width = domRect.width;
      this.config.height = domRect.height;
      let oldSvg = this.d3.select("svg");
      // 如果宿主元素中包含svg标签了,那么则删除这个标签,再重新生成一个
      if (!oldSvg.empty()) {
        oldSvg.remove();
      }
      const svg = this.d3
        .create("svg")
        .attr("viewBox", () => {
          let parentsLength = this.originTreeData.parents
            ? this.originTreeData.parents.length
            : 0;
          return [
            -this.config.width / 2,
            // 如果有父节点,则根节点居中,否则根节点上浮一段距离
            parentsLength > 0
              ? -this.config.height / 2
              : -this.config.height / 3,
            this.config.width,
            this.config.height,
          ];
        })
        .style("user-select", "none")
        .style("cursor", "move");

      // 包括连接线和节点的总集合
      const gAll = svg.append("g").attr("id", "all");
      svg
        .call(
          this.d3
            .zoom()
            .scaleExtent([0.2, 5])
            .on("zoom", (e) => {
              gAll.attr("transform", () => {
                return `translate(${e.transform.x},${e.transform.y}) scale(${e.transform.k})`;
              });
            })
        )
        .on("dblclick.zoom", null); // 取消默认的双击放大事件
      this.gAll = gAll;
      // 连接线集合
      this.gLinks = gAll.append("g").attr("id", "linkGroup");
      // 节点集合
      this.gNodes = gAll.append("g").attr("id", "nodeGroup");
      // 设置好节点之间距离的tree方法
      this.tree = this.d3.tree().nodeSize([this.config.dx, this.config.dy]);
      this.rootOfDown = this.d3.hierarchy(
        this.originTreeData,
        (d) => d.children
      );
      this.rootOfUp = this.d3.hierarchy(this.originTreeData, (d) => d.parents);
      this.tree(this.rootOfDown);
      [this.rootOfDown.descendants(), this.rootOfUp.descendants()].forEach(
        (nodes) => {
          nodes.forEach((node) => {
            node._children = node.children || null;
            if (options.type === "all") {
              //如果是all的话,则表示全部都展开
              node.children = node._children;
            } else if (options.type === "fold") {
              //如果是fold则表示除了父节点全都折叠
              // 将非根节点的节点都隐藏掉(其实对于这个组件来说加不加都一样)
              if (node.depth) {
                node.children = null;
              }
            }
          });
        }
      );
      //箭头(下半部分)
      svg
        .append("marker")
        .attr("id", "markerOfDown")
        .attr("markerUnits", "userSpaceOnUse")
        .attr("viewBox", "0 -5 10 10") //坐标系的区域
        .attr("refX", 55) //箭头坐标
        .attr("refY", 0)
        .attr("markerWidth", 10) //标识的大小
        .attr("markerHeight", 10)
        .attr("orient", "90") //绘制方向,可设定为:auto(自动确认方向)和 角度值
        .attr("stroke-width", 2) //箭头宽度
        .append("path")
        .attr("d", "M0,-5L10,0L0,5") //箭头的路径
        .attr("fill", "#215af3"); //箭头颜色
      //箭头(上半部分)
      svg
        .append("marker")
        .attr("id", "markerOfUp")
        .attr("markerUnits", "userSpaceOnUse")
        .attr("viewBox", "0 -5 10 10") //坐标系的区域
        .attr("refX", -50) //箭头坐标
        .attr("refY", 0)
        .attr("markerWidth", 10) //标识的大小
        .attr("markerHeight", 10)
        .attr("orient", "90") //绘制方向,可设定为:auto(自动确认方向)和 角度值
        .attr("stroke-width", 2) //箭头宽度
        .append("path")
        .attr("d", "M0,-5L10,0L0,5") //箭头的路径
        .attr("fill", "#215af3"); //箭头颜色
      this.svg = svg;
      this.update();
      // 将svg置入宿主元素中
      host.append(function () {
        return svg.node();
      });
    },
    // 更新数据
    update(source) {
      if (!source) {
        source = {
          x0: 0,
          y0: 0,
        };
        // 设置根节点所在的位置(原点)
        this.rootOfDown.x0 = 0;
        this.rootOfDown.y0 = 0;
        this.rootOfUp.x0 = 0;
        this.rootOfUp.y0 = 0;
      }
      let nodesOfDown = this.rootOfDown.descendants().reverse();
      let linksOfDown = this.rootOfDown.links();
      let nodesOfUp = this.rootOfUp.descendants().reverse();
      let linksOfUp = this.rootOfUp.links();
      this.tree(this.rootOfDown);
      this.tree(this.rootOfUp);
      const myTransition = this.svg.transition().duration(500);
      /***  绘制子公司树  ***/
      const node1 = this.gNodes
        .selectAll("g.nodeOfDownItemGroup")
        .data(nodesOfDown, (d) => {
          return d.data.id;
        });
      const node1Enter = node1
        .enter()
        .append("g")
        .attr("class", "nodeOfDownItemGroup")
        .attr("transform", (d) => {
          return `translate(${source.x0},${source.y0})`;
        })
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0)
        .style("cursor", "pointer");
      // 外层的矩形框
      node1Enter
        .append("rect")
        .attr("width", (d) => {
          if (d.depth === 0) {
            return (d.data.name.length + 2) * 16;
          }
          return this.config.rectWidth;
        })
        .attr("height", (d) => {
          if (d.depth === 0) {
            return 30;
          }
          return this.config.rectHeight;
        })
        .attr("x", (d) => {
          if (d.depth === 0) {
            return (-(d.data.name.length + 2) * 16) / 2;
          }
          return -this.config.rectWidth / 2;
        })
        .attr("y", (d) => {
          if (d.depth === 0) {
            return -15;
          }
          return -this.config.rectHeight / 2;
        })
        .attr("rx", 5)
        .attr("stroke-width", 1)
        .attr("stroke", (d) => {
          if (d.depth === 0) {
            return "#5682ec";
          }
          return "#7A9EFF";
        })
        .attr("fill", (d) => {
          if (d.depth === 0) {
            return "#7A9EFF";
          }
          return "#FFFFFF";
        })
        .on("click", (e, d) => {
          this.nodeClickEvent(e, d);
        });
      // 文本主标题
      node1Enter
        .append("text")
        .attr("class", "main-title")
        .attr("x", (d) => {
          return 0;
        })
        .attr("y", (d) => {
          if (d.depth === 0) {
            return 5;
          }
          return -14;
        })
        .attr("text-anchor", (d) => {
          return "middle";
        })
        .text((d) => {
          if (d.depth === 0) {
            return d.data.name;
          } else {
            return d.data.name.length > 11
              ? d.data.name.substring(0, 11)
              : d.data.name;
          }
        })
        .attr("fill", (d) => {
          if (d.depth === 0) {
            return "#FFFFFF";
          }
          return "#000000";
        })
        .style("font-size", (d) => (d.depth === 0 ? 16 : 14))
        .style("font-family", "黑体")
        .style("font-weight", "bold");
      // 副标题
      node1Enter
        .append("text")
        .attr("class", "sub-title")
        .attr("x", (d) => {
          return 0;
        })
        .attr("y", (d) => {
          return 5;
        })
        .attr("text-anchor", (d) => {
          return "middle";
        })
        .text((d) => {
          if (d.depth !== 0) {
            let subTitle = d.data.name.substring(11);
            if (subTitle.length > 10) {
              return subTitle.substring(0, 10) + "...";
            }
            return subTitle;
          }
        })
        .style("font-size", (d) => 14)
        .style("font-family", "黑体")
        .style("font-weight", "bold");

      // 控股比例
      node1Enter
        .append("text")
        .attr("class", "percent")
        .attr("x", (d) => {
          return 12;
        })
        .attr("y", (d) => {
          return -45;
        })
        .text((d) => {
          if (d.depth !== 0) {
            return d.data.percent;
          }
        })
        .attr("fill", "#000000")
        .style("font-family", "黑体")
        .style("font-size", (d) => 14);

      // 增加展开按钮
      const expandBtnG = node1Enter
        .append("g")
        .attr("class", "expandBtn")
        .attr("transform", (d) => {
          return `translate(${0},${this.config.rectHeight / 2})`;
        })
        .style("display", (d) => {
          // 如果是根节点,不显示
          if (d.depth === 0) {
            return "none";
          }
          // 如果没有子节点,则不显示
          if (!d._children) {
            return "none";
          }
        })
        .on("click", (e, d) => {
          if (d.children) {
            d._children = d.children;
            d.children = null;
          } else {
            d.children = d._children;
          }
          this.update(d);
        });

      expandBtnG
        .append("circle")
        .attr("r", 8)
        .attr("fill", "#7A9EFF")
        .attr("cy", 8);

      expandBtnG
        .append("text")
        .attr("text-anchor", "middle")
        .attr("fill", "#ffffff")
        .attr("y", 13)
        .style("font-size", 16)
        .style("font-family", "微软雅黑")
        .text((d) => {
          return d.children ? "-" : "+";
        });

      const link1 = this.gLinks
        .selectAll("path.linkOfDownItem")
        .data(linksOfDown, (d) => d.target.data.id);

      const link1Enter = link1
        .enter()
        .append("path")
        .attr("class", "linkOfDownItem")
        .attr("d", (d) => {
          let o = {
            source: {
              x: source.x0,
              y: source.y0,
            },
            target: {
              x: source.x0,
              y: source.y0,
            },
          };
          return this.drawLink(o);
        })
        .attr("fill", "none")
        .attr("stroke", "#7A9EFF")
        .attr("stroke-width", 1)
        .attr("marker-end", "url(#markerOfDown)");

      // 有元素update更新和元素新增enter的时候
      node1
        .merge(node1Enter)
        .transition(myTransition)
        .attr("transform", (d) => {
          return `translate(${d.x},${d.y})`;
        })
        .attr("fill-opacity", 1)
        .attr("stroke-opacity", 1);

      // 有元素消失时
      node1
        .exit()
        .transition(myTransition)
        .remove()
        .attr("transform", (d) => {
          return `translate(${source.x0},${source.y0})`;
        })
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0);

      link1.merge(link1Enter).transition(myTransition).attr("d", this.drawLink);

      link1
        .exit()
        .transition(myTransition)
        .remove()
        .attr("d", (d) => {
          let o = {
            source: {
              x: source.x,
              y: source.y,
            },
            target: {
              x: source.x,
              y: source.y,
            },
          };
          return this.drawLink(o);
        });

      /***  绘制股东树  ***/

      nodesOfUp.forEach((node) => {
        node.y = -node.y;
      });

      const node2 = this.gNodes
        .selectAll("g.nodeOfUpItemGroup")
        .data(nodesOfUp, (d) => {
          return d.data.id;
        });

      const node2Enter = node2
        .enter()
        .append("g")
        .attr("class", "nodeOfUpItemGroup")
        .attr("transform", (d) => {
          return `translate(${source.x0},${source.y0})`;
        })
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0)
        .style("cursor", "pointer");

      // 外层的矩形框
      node2Enter
        .append("rect")
        .attr("width", (d) => {
          if (d.depth === 0) {
            return (d.data.name.length + 2) * 16;
          }
          return this.config.rectWidth;
        })
        .attr("height", (d) => {
          if (d.depth === 0) {
            return 30;
          }
          return this.config.rectHeight;
        })
        .attr("x", (d) => {
          if (d.depth === 0) {
            return (-(d.data.name.length + 2) * 16) / 2;
          }
          return -this.config.rectWidth / 2;
        })
        .attr("y", (d) => {
          if (d.depth === 0) {
            return -15;
          }
          return -this.config.rectHeight / 2;
        })
        .attr("rx", 5)
        .attr("stroke-width", 1)
        .attr("stroke", (d) => {
          if (d.depth === 0) {
            return "#5682ec";
          }
          return "#7A9EFF";
        })
        .attr("fill", (d) => {
          if (d.depth === 0) {
            return "#7A9EFF";
          }
          return "#FFFFFF";
        })
        .on("click", (e, d) => {
          this.nodeClickEvent(e, d);
        });
      // 文本主标题
      node2Enter
        .append("text")
        .attr("class", "main-title")
        .attr("x", (d) => {
          return 0;
        })
        .attr("y", (d) => {
          if (d.depth === 0) {
            return 5;
          }
          return -14;
        })
        .attr("text-anchor", (d) => {
          return "middle";
        })
        .text((d) => {
          if (d.depth === 0) {
            return d.data.name;
          } else {
            return d.data.name.length > 11
              ? d.data.name.substring(0, 11)
              : d.data.name;
          }
        })
        .attr("fill", (d) => {
          if (d.depth === 0) {
            return "#FFFFFF";
          }
          return "#000000";
        })
        .style("font-size", (d) => (d.depth === 0 ? 16 : 14))
        .style("font-family", "黑体")
        .style("font-weight", "bold");
      // 副标题
      node2Enter
        .append("text")
        .attr("class", "sub-title")
        .attr("x", (d) => {
          return 0;
        })
        .attr("y", (d) => {
          return 5;
        })
        .attr("text-anchor", (d) => {
          return "middle";
        })
        .text((d) => {
          if (d.depth !== 0) {
            let subTitle = d.data.name.substring(11);
            if (subTitle.length > 10) {
              return subTitle.substring(0, 10) + "...";
            }
            return subTitle;
          }
        })
        .style("font-size", (d) => 14)
        .style("font-family", "黑体")
        .style("font-weight", "bold");

      // 控股比例
      node2Enter
        .append("text")
        .attr("class", "percent")
        .attr("x", (d) => {
          return 12;
        })
        .attr("y", (d) => {
          return 55;
        })
        .text((d) => {
          if (d.depth !== 0) {
            return d.data.percent;
          }
        })
        .attr("fill", "#000000")
        .style("font-family", "黑体")
        .style("font-size", (d) => 14);

      // 增加展开按钮
      const expandBtnG2 = node2Enter
        .append("g")
        .attr("class", "expandBtn")
        .attr("transform", (d) => {
          return `translate(${0},${-this.config.rectHeight / 2})`;
        })
        .style("display", (d) => {
          // 如果是根节点,不显示
          if (d.depth === 0) {
            return "none";
          }
          // 如果没有子节点,则不显示
          if (!d._children) {
            return "none";
          }
        })
        .on("click", (e, d) => {
          if (d.children) {
            d._children = d.children;
            d.children = null;
          } else {
            d.children = d._children;
          }
          this.update(d);
        });

      expandBtnG2
        .append("circle")
        .attr("r", 8)
        .attr("fill", "#7A9EFF")
        .attr("cy", -8);

      expandBtnG2
        .append("text")
        .attr("text-anchor", "middle")
        .attr("fill", "#ffffff")
        .attr("y", -3)
        .style("font-size", 16)
        .style("font-family", "微软雅黑")
        .text((d) => {
          return d.children ? "-" : "+";
        });

      const link2 = this.gLinks
        .selectAll("path.linkOfUpItem")
        .data(linksOfUp, (d) => d.target.data.id);

      const link2Enter = link2
        .enter()
        .append("path")
        .attr("class", "linkOfUpItem")
        .attr("d", (d) => {
          let o = {
            source: {
              x: source.x0,
              y: source.y0,
            },
            target: {
              x: source.x0,
              y: source.y0,
            },
          };
          return this.drawLink(o);
        })
        .attr("fill", "none")
        .attr("stroke", "#7A9EFF")
        .attr("stroke-width", 1)
        .attr("marker-end", "url(#markerOfUp)");

      // 有元素update更新和元素新增enter的时候
      node2
        .merge(node2Enter)
        .transition(myTransition)
        .attr("transform", (d) => {
          return `translate(${d.x},${d.y})`;
        })
        .attr("fill-opacity", 1)
        .attr("stroke-opacity", 1);
      // 有元素消失时
      node2
        .exit()
        .transition(myTransition)
        .remove()
        .attr("transform", (d) => {
          return `translate(${source.x0},${source.y0})`;
        })
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0);
      link2.merge(link2Enter).transition(myTransition).attr("d", this.drawLink);
      link2
        .exit()
        .transition(myTransition)
        .remove()
        .attr("d", (d) => {
          let o = {
            source: {
              x: source.x,
              y: source.y,
            },
            target: {
              x: source.x,
              y: source.y,
            },
          };
          return this.drawLink(o);
        });
      // node数据改变的时候更改一下加减号
      const expandButtonsSelection = this.d3.selectAll("g.expandBtn");
      expandButtonsSelection
        .select("text")
        .transition()
        .text((d) => {
          return d.children ? "-" : "+";
        });

      this.rootOfDown.eachBefore((d) => {
        d.x0 = d.x;
        d.y0 = d.y;
      });
      this.rootOfUp.eachBefore((d) => {
        d.x0 = d.x;
        d.y0 = d.y;
      });
    },
    // 直角连接线 by wushengyuan
    drawLink({ source, target }) {
      const halfDistance = (target.y - source.y) / 2;
      const halfY = source.y + halfDistance;
      return `M${source.x},${source.y} L${source.x},${halfY} ${target.x},${halfY} ${target.x},${target.y}`;
    },
    // 展开所有的节点
    expandAllNodes() {
      this.drawChart({
        type: "all",
      });
    },
    // 将所有节点都折叠
    foldAllNodes() {
      this.drawChart({
        type: "fold",
      });
    },
    //点击节点获取节点数据
    nodeClickEvent(e, d) {
      console.log("当前节点的数据:", d);
    },
  },
};
</script>
<style lang="scss" scoped>
</style>

参考git地址

https://gitee.com/wushengyuan/equity-penetration-chart

留言:
1:点击展开节点按钮再加载子节点数据还在尝试中。所以只能是让后端一次性返回所有数据。
2:数据只能单侧延伸。解决思路:还在尝试跟之前relation-graph实现股权穿透图文章一样点击节点后,弹窗作为根节点展示此节点的穿透图。
3:欢迎大家有更好的实现方案沟通交流。

评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值