Vue3+D3实现公司股权分配图

155 阅读5分钟

D3官网

D3.js(Data-Driven Documents)是一个用于操作文档数据的JavaScript库,主要用于创建基于数据的动态和交互式数据可视化。它的核心功能包括:

  1. 数据绑定:D3.js可以将数据与DOM元素绑定,使得数据的变化可以自动反映在页面上。
  2. SVG支持:D3.js可以创建和操作SVG元素,用于生成图表、地图和其他视觉元素。
  3. 动画和过渡:D3.js支持丰富的动画效果,可以使数据可视化过程更加生动。
  4. 灵活性:D3.js提供了很多工具和函数,可以对数据进行操作和转换,支持各种图表和可视化形式的创建。

效果图

image.png

演示效果

组件代码

<template>
  <div id="flow-box">
    <!-- 操作按钮组 -->
    <div class="tool-box">
      <div class="tool-item" @click="toggleFullScreen">
        <el-tooltip
          class="box-item"
          effect="dark"
          content="全屏"
          placement="left"
        >
          <el-icon><FullScreen /></el-icon>
        </el-tooltip>
      </div>
      <div class="tool-item" @click="expandAllNodes()">
        <el-tooltip
          class="box-item"
          effect="dark"
          content="全部展开"
          placement="left"
        >
          <el-icon><CaretBottom /></el-icon>
        </el-tooltip>
      </div>
      <div class="tool-item" @click="foldAllNodes()">
        <el-tooltip
          class="box-item"
          effect="dark"
          content="全部收起"
          placement="left"
        >
          <el-icon><CaretTop /></el-icon>
        </el-tooltip>
      </div>
      <div class="tool-item" @click="foldAllNodes()">
        <el-tooltip
          class="box-item"
          effect="dark"
          content="刷新"
          placement="left"
        >
          <el-icon><Refresh /></el-icon>
        </el-tooltip>
      </div>
      <div class="tool-item" @click="downloadChart">
        <el-tooltip
          class="box-item"
          effect="dark"
          content="下载"
          placement="left"
        >
          <el-icon><Download /></el-icon>
        </el-tooltip>
      </div>
    </div>
    <!-- 绘制架构图盒子 -->
    <div id="chartBox" ref="chartBox" :style="styles"></div>
  </div>
</template>

<script setup>
import * as d3 from 'd3'
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { ElTooltip } from 'element-plus'
import {
  Refresh,
  CaretBottom,
  CaretTop,
  FullScreen,
  Download,
} from '@element-plus/icons-vue'

const props = defineProps({
  styles: {
    type: Object,
    default: () => ({
      height: '680px',
    }),
  },
  // 数据源 
  data: {
    type: Object,
    // 默认数据格式
    default: () => ({
      id: '10001',
      name: '广东XXX集团公司',
      children: [
        {
          id: '10002',
          name: '深圳市XXX投资有限公司',
          percent: '100%',
          rounds: '天使轮',
        },
        {
          id: '10003',
          name: '深圳市XXX技术有限公司',
          percent: '100%',
          rounds: 'A轮',
        },
        {
          id: '10004',
          name: '深圳市XXX光伏材料有限公司',
          percent: '100%',
          rounds: '被收购',
          off: true,
        },
        {
          id: '10005',
          name: '深圳市XXX医疗科技有限公司',
          percent: '100%',
          children: [
            {
              id: '10006',
              name: '深圳市XXX医疗仪器有限公司',
              percent: '100%',
              children: [
                {
                  id: '10007',
                  name: '深圳市XXX的子公司一',
                  percent: '80%',
                },
                {
                  id: '10008',
                  name: '深圳市XXX的子公司二',
                  percent: '90%',
                },
                {
                  id: '10009',
                  name: '深圳市XXX的子公司三',
                  percent: '100%',
                },
              ],
            },
          ],
        },
        {
          id: '100010',
          name: '上海XXX科技有限公司',
          percent: '100%',
          children: [
            {
              id: '100011',
              name: '上海XXX通讯设备有限公司',
              percent: '100%',
              children: [
                {
                  id: '100012',
                  name: '上海XXX的子公司一',
                  percent: '100%',
                },
                {
                  id: '100013',
                  name: '上海XXX的子公司二',
                  percent: '90%',
                },
              ],
            },
          ],
        },
        {
          id: '100014',
          name: '北京XXX科技有限公司',
          percent: '100%',
          children: [
            {
              id: '100015',
              name: '北京XXX网络有限公司',
            },
          ],
        },
      ],
    }),
  },
});



// 基础配置项
const config = ref({
  // 节点的横向距离
  dx: 200,
  // 节点的纵向距离
  dy: 170,
  // svg的viewBox的宽度
  width: 0,
  // svg的viewBox的高度
  height: 650,
  // 节点的矩形框宽度
  rectWidth: 170,
  // 节点的矩形框高度
  rectHeight: 70,
})

const svgs = ref(null)
const gAlls = ref(null)
const gLinks = ref(null)
const gNodes = ref(null)
// 给树加坐标点的方法
const tree = ref(null)
// 投资公司树的根节点
const rootDom = ref(null)
// 股东树的根节点
const childDom = ref(null)
// 是否全屏
const isFullscreen = ref(true)

onMounted(() => {
  drawChart({
    type: 'fold',
  })
  // 监听窗口大小变化
  window.addEventListener('resize', handleResize)
})

const handleResize = () => {
  // ESC退出全屏改变图表
  if (!checkFull()) {
    isFullscreen.value = true
  }
  svg.setAttribute('height', window.innerHeight)
}

// 在组件销毁时移除事件监听
onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
})

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

  // 包括连接线和节点的总集合
  const gAll = svg.append('g').attr('id', 'all')
  svg
    .call(
      d3
        .zoom()
        .scaleExtent([0.5, 2])
        .on('zoom', (e) => {
          gAll.attr('transform', () => {
            return `translate(${e.transform.x},${e.transform.y}) scale(${e.transform.k})`
          })
        })
    )
    .on('dblclick.zoom', null) // 取消默认的双击放大事件
  gAlls.value = gAll
  // 连接线集合
  gLinks.value = gAll.append('g').attr('id', 'linkGroup')
  // 节点集合
  gNodes.value = gAll.append('g').attr('id', 'nodeGroup')
  // 设置好节点之间距离的tree方法
  tree.value = d3.tree().nodeSize([config.value.dx, config.value.dy])
  rootDom.value = d3.hierarchy(props.data, (d) => d.children)
  childDom.value = d3.hierarchy(props.data, (d) => d.parents)
  tree.value(rootDom.value)
  ;[rootDom.value.descendants(), childDom.value.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') //箭头颜色
  svgs.value = svg
  update()
  // 将svg置入宿主元素中
  host.append(function () {
    return svg.node()
  })
}
// 更新数据
function update(source) {
  if (!source) {
    source = {
      x0: 0,
      y0: 0,
    }
    // 设置根节点所在的位置(原点)
    rootDom.value.x0 = 0
    rootDom.value.y0 = 0
    childDom.value.x0 = 0
    childDom.value.y0 = 0
  }
  let nodesOfDown = rootDom.value.descendants().reverse()
  let linksOfDown = rootDom.value.links()
  let nodesOfUp = childDom.value.descendants().reverse()
  let linksOfUp = childDom.value.links()
  tree.value(rootDom.value)
  tree.value(childDom.value)
  const myTransition = svgs.value.transition().duration(500)
  // 绘制子公司树
  const node1 = gNodes.value
    .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 config.value.rectWidth
    })
    .attr('height', (d) => {
      if (d.depth === 0) {
        return 40
      }
      return config.value.rectHeight
    })
    .attr('x', (d) => {
      if (d.depth === 0) {
        return (-(d.data.name.length + 2) * 16) / 2
      }
      return -config.value.rectWidth / 2
    })
    .attr('y', (d) => {
      if (d.depth === 0) {
        return -15
      }
      return -config.value.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) => {
      nodeClickEvent(e, d)
    })
  // 文本主标题
  node1Enter
    .append('text')
    .attr('class', 'main-title')
    .attr('x', (d) => {
      return 0
    })
    .attr('y', (d) => {
      if (d.depth === 0) {
        return 10
      }
      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')
    .append('svg:title')
    .text((d) => d.data.name)

  // 副标题
  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) + '...'
        } else {
          return subTitle
        }
      }
    })
    .style('font-size', (d) => 14)
    .style('font-family', '黑体')
    .style('font-weight', 'bold')
    .append('svg:title')
    .text((d) => d.data.name)
  // 融资轮次
  node1Enter
    .append('svg:rect')
    .attr('x', '-84')
    .attr('y', '14')
    .attr('rx', 5)
    .attr('width', (d) => {
      if (d.depth !== 0 && d.data.rounds) {
        return config.value.rectWidth - 2
      }
    })
    .attr('height', '20')
    .style('fill', '#E5F3FE')
  node1Enter
    .append('text')
    .attr('x', '0')
    .attr('dy', '30')
    .attr('text-anchor', 'middle')

    .attr('class', 'linkname')
    // .style("fill", "#666")
    .style('font-size', 12)
    .attr('fill', '#008BF8')
    .text(function (d) {
      if (d.depth !== 0 && d.data.rounds) {
        var str = '融资轮次:' + d.data.rounds
        return str.length > 13 ? str.substring(0, 18) + '..' : str
      }
    })

  // 控股比例
  node1Enter
    .append('text')
    .attr('class', 'percent')
    .attr('x', (d) => {
      return 15
    })
    .attr('y', (d) => {
      return -55
    })
    .text((d) => {
      if (d.depth !== 0) {
        return d.data.percent
      }
    })
    .attr('fill', '#0084FF')
    .style('font-family', '黑体')
    .style('font-size', (d) => 14)
  node1Enter
    .append('svg:rect')
    .attr('x', '-40')
    .attr('y', '-70')
    .attr('rx', 2)
    .attr('width', function (d) {
      return d.depth !== 0 && d.data.percent ? 30 : 0
    })
    .attr('height', function (d) {
      return d.depth !== 0 && d.data.percent ? 20 : 0
    })
    .style('fill', '#EBF5FF')
  node1Enter
    .append('text')
    .attr('x', '-37')
    .attr('dy', '-55')
    .attr('text-anchor', 'start')
    .style('fill', '#0084FF')
    .style('font-size', 12)
    .text(function (d) {
      return d.depth !== 0 && d.data.percent ? '控股' : ''
    })
  // 吊销/注销
  node1Enter
    .append('svg:rect')
    .attr('x', '10')
    .attr('y', '-48')
    .attr('rx', 2)
    .attr('width', function (d) {
      return d.depth !== 0 && d.data.off ? 45 : 0
    })
    .attr('height', function (d) {
      return d.depth !== 0 && d.data.off ? 10 : 0
    })
    .style('fill', '#fde2e2')
  node1Enter
    .append('text')
    .attr('x', '15')
    .attr('dy', '-40')
    .attr('text-anchor', 'start')
    .style('fill', '#f89898')
    .style('font-size', 8)
    .text(function (d) {
      return d.depth !== 0 && d.data.off ? '吊销/注销' : ''
    })
  // 增加展开按钮
  const expandBtnG = node1Enter
    .append('g')
    .attr('class', 'expandBtn')
    .attr('transform', (d) => {
      return `translate(${0},${config.value.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
      }
      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 = gLinks.value
    .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 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', 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 drawLink(o)
    })

  // 绘制股东树

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

  const node2 = gNodes.value
    .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 config.value.rectWidth
    })
    .attr('height', (d) => {
      if (d.depth === 0) {
        return 40
      }
      return config.value.rectHeight
    })
    .attr('x', (d) => {
      if (d.depth === 0) {
        return (-(d.data.name.length + 2) * 16) / 2
      }
      return -config.value.rectWidth / 2
    })
    .attr('y', (d) => {
      if (d.depth === 0) {
        return -15
      }
      return -config.value.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) => {
      nodeClickEvent(e, d)
    })
  // 文本主标题
  node2Enter
    .append('text')
    .attr('class', 'main-title')
    .attr('x', (d) => {
      return 0
    })
    .attr('y', (d) => {
      if (d.depth === 0) {
        return 10
      }
      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')
    .append('svg:title')
    .text((d) => d.data.name)
  // 副标题
  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')
    .append('svg:title')
    .text((d) => d.data.name)

  // 控股比例
  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', function (d) {
      return d.data.controlling ? '#FA6B64' : '#000000'
    })
    .style('font-family', '黑体')
    .style('font-size', (d) => 14)
  // 地址
  node2Enter
    .append('svg:rect')
    .attr('x', '50')
    .attr('y', '-55')
    .attr('rx', 2)
    .attr('width', function (d) {
      return d.depth !== 0 && d.data.address ? 28 : 0
    })
    .attr('height', function (d) {
      return d.depth !== 0 && d.data.address ? 15 : 0
    })
    .style('fill', ' #e1f3d8')
  node2Enter
    .append('text')
    .attr('x', '53')
    .attr('dy', '-44')
    .attr('text-anchor', 'start')
    .style('fill', '#95d475')
    .style('font-size', 10)
    .text(function (d) {
      return d.depth !== 0 && d.data.address ? d.data.address : ''
    })
  // 实际控股人
  node2Enter
    .append('svg:rect')
    .attr('x', -50)
    .attr('y', -90)
    .attr('width', function (d) {
      return d.data.controlling ? 100 : 0
    })
    .attr('height', function (d) {
      return d.data.controlling ? 40 : 0
    })
    .attr('rx', 2)
    .style('stroke', function (d) {
      return d.data.controlling ? '#FA6B64' : '#F1B03A'
    })
    .style('fill', function (d) {
      return d.data.controlling ? '#FA6B64' : '#F1B03A' //节点背景色
    })
  node2Enter
    .append('svg:path')
    .attr('fill', (d) => {
      return d.data.controlling ? '#FA6B64' : '#F1B03A'
    })
    .attr('d', function (d) {
      if (d.data.controlling) {
        return 'M0 -44 L-10 -54 L10 -54 Z'
      } else {
        return ''
      }
    })
  node2Enter
    .append('text')
    .attr('x', '0')
    .attr('dy', '-74')
    .attr('text-anchor', 'middle')
    .style('fill', '#fff')
    .style('font-size', 12)
    .text(function (d) {
      return d.data.controlling ? '实际控制人' : ''
    })
  node2Enter
    .append('text')
    .attr('x', '0')
    .attr('dy', '-58')
    .attr('text-anchor', 'middle')
    .style('fill', '#fff')
    .style('font-size', 12)
    .text(function (d) {
      return d.data.controlling ? '受益所有人' : ''
    })
  // 增加展开按钮
  const expandBtnG2 = node2Enter
    .append('g')
    .attr('class', 'expandBtn')
    .attr('transform', (d) => {
      return `translate(${0},${-config.value.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
      }
      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 = gLinks.value
    .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 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', 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 drawLink(o)
    })
  // node数据改变的时候更改一下加减号
  const expandButtonsSelection = d3.selectAll('g.expandBtn')
  expandButtonsSelection
    .select('text')
    .transition()
    .text((d) => {
      return d.children ? '-' : '+'
    })

  rootDom.value.eachBefore((d) => {
    d.x0 = d.x
    d.y0 = d.y
  })
  childDom.value.eachBefore((d) => {
    d.x0 = d.x
    d.y0 = d.y
  })
}
// 直角连接线
function 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}`
}
// 展开所有的节点
function expandAllNodes() {
  drawChart({
    type: 'all',
  })
}
// 将所有节点都折叠
function foldAllNodes() {
  drawChart({
    type: 'fold',
  })
}
// 点击节点获取节点数据
function nodeClickEvent(e, d) {
  // console.log('当前节点的数据:', d)
}
// 全屏-退出全屏
function toggleFullScreen(e) {
  isFullscreen.value = !isFullscreen.value
  getFullScreen(document.getElementById('flow-box'))
}
// 全屏
function fullele() {
  return (
    document.fullscreenElement ||
    document.webkitFullscreenElement ||
    document.msFullscreenElement ||
    document.mozFullScreenElement ||
    null
  )
}
// 判断是否为全屏
function checkFull() {
  return !!(document.webkitIsFullScreen || fullele())
}
// 全屏-退出全屏
function getFullScreen(el) {
  if (isFullscreen.value) {
    //退出全屏
    if (document.exitFullscreen) {
      document.exitFullscreen()
    } else if (document.mozCancelFullScreen) {
      document.mozCancelFullScreen()
    } else if (document.webkitExitFullscreen) {
      document.webkitExitFullscreen()
    } else if (!document.msRequestFullscreen) {
      document.msExitFullscreen()
    }
  } else {
    //进入全屏
    if (el.requestFullscreen) {
      el.requestFullscreen()
    } else if (el.mozRequestFullScreen) {
      el.mozRequestFullScreen()
    } else if (el.webkitRequestFullscreen) {
      //改变平面图在google浏览器上面的样式问题
      el.webkitRequestFullscreen()
    } else if (el.msRequestFullscreen) {
      isFullscreen.value = true
      el.msRequestFullscreen()
    }
  }
}
// 下载为图片
function downloadChart(chartName) {
  // 得到svg的真实大小
  let svg = document.getElementById('mysvg')
  let box = document.querySelector('svg').getBBox(),
    x = box.x,
    y = box.y,
    width = box.width * 2,
    height = box.height * 2
  // 克隆svg
  var node = svg.cloneNode(true)
  //重新设置svg的width,height,viewbox
  node.setAttribute('width', width * 2)
  node.setAttribute('height', height * 2)
  node.setAttribute('viewBox', [x, y, width, height])
  downloadSvg(node, width, height, props.data.name)
}
// 下载
function downloadSvg(svg, width, height, rootName) {
  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 canvas = document.createElement('canvas')
    canvas.width = width + 40
    canvas.height = height + 40
    var context = canvas.getContext('2d')
    context.rect(0, 0, canvas.width, canvas.height)
    context.fillStyle = '#fff'
    context.fill()
    context.drawImage(image, 20, 20)
    var url = canvas.toDataURL('image/png')
    var a = document.createElement('a')
    a.download = `${rootName}-股权穿透图.png`
    a.href = url
    a.click()
    return
  }
}
</script>

<style lang="scss" scoped>
#flow-box {
  background: #fff;
  position: relative;
}
.tool-box {
  padding: 5px;
  position: absolute;
  right: 10px;
  bottom: 50%;
  display: flex;
  flex-direction: column;
  background: #eee;
  transition: all linear 0.3s;
  border-radius: 10px;

  .tool-item {
    color: #333;
    height: 36px;
    width: 36px;
    display: flex;
    justify-content: center;
    align-items: center;
    cursor: pointer;
    border-radius: 6px;
  }
  tool-item > img {
    width: 100%;
    height: 100%;
  }
  .tool-item:hover {
    background: rgb(233, 233, 233);
  }
}
</style>

组件调用

<template>
  <D3Pane :data="chartData" />
</template>

<script setup lang="ts">
import D3Pane from './components/D3Pane.vue'

// 数据源
const chartData = ref({
  id: '10001',
  // 根节点名称
  name: '广东XXX集团公司',
  // 子节点列表
  children: [
    {
      id: '10002',
      name: '深圳市XXX投资有限公司',
      percent: '100%', // 控股比例
      rounds: '天使轮', // 融资轮次
    },
    {
      id: '10003',
      name: '深圳市XXX技术有限公司',
      percent: '100%',
      rounds: 'A轮',
    },
    {
      id: '10004',
      name: '深圳市XXX光伏材料有限公司',
      percent: '100%',
      rounds: '被收购',
      off: true, // 吊销/注销
    },
    {
      id: '10005',
      name: '深圳市XXX医疗科技有限公司',
      percent: '100%',
      children: [
        {
          id: '10006',
          name: '深圳市XXX医疗仪器有限公司',
          percent: '100%',
          children: [
            {
              id: '10007',
              name: '深圳市XXX的子公司一',
              percent: '80%',
            },
            {
              id: '10008',
              name: '深圳市XXX的子公司二',
              percent: '90%',
            },
            {
              id: '10009',
              name: '深圳市XXX的子公司三',
              percent: '100%',
            },
          ],
        },
      ],
    },
    {
      id: '100010',
      name: '上海XXX科技有限公司',
      percent: '100%',
      children: [
        {
          id: '100011',
          name: '上海XXX通讯设备有限公司',
          percent: '100%',
          children: [
            {
              id: '100012',
              name: '上海XXX的子公司一',
              percent: '100%',
            },
            {
              id: '100013',
              name: '上海XXX的子公司二',
              percent: '90%',
            },
          ],
        },
      ],
    },
    {
      id: '100014',
      name: '北京XXX科技有限公司',
      percent: '100%',
      children: [
        {
          id: '100015',
          name: '北京XXX网络有限公司',
        },
      ],
    },
  ],
})

总结

摸索借鉴文档学习,到这里就解决了效果如图,通过这个效果学习了d3的强大功能之一~