一步一步用d3做树图(一)

3,175 阅读2分钟

第一版

说明

  • 实现纯展示
  • 通过tree() .size([width, height - 30])指定了树图的大小,从而使得节点的位置会自动居中和缩放

参考文章

d3.js树图示例 - 掘金 (juejin.cn)

d3 树状布局tree - 简书 (jianshu.com)

d3.js中的树状图(一)-秋天爱美丽-专业的技术网站 (qiutianaimeili.com) <-- 这个文章对于每一步的讲解很详细,但没有完整代码

d3.js中的hierarchy/stratify-秋天… <-- d3中需要将原始的树结构数据通过hierarchy进行一次转换,该文章介绍了hierarchy的详细用法和转换后节点的属性说明

效果

image.png

代码

<!--
TODO: 编写组件说明
@author pan
@date 2022-04-29
-->
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { select } from 'd3-selection'
import { hierarchy, HierarchyPointNode, tree } from 'd3-hierarchy'

const myContainerRef = ref<HTMLDivElement>()

onMounted(() => {
  const myContainerDom = myContainerRef.value as HTMLDivElement
  const data = {
    name: '中国',
    children: [
      {
        name: '浙江',
        children: [
          { name: '杭州', value: 100 },
          { name: '宁波', value: 200 },
          { name: '温州', value: 30 },
          { name: '绍兴', value: 50 },
        ],
      },
      {
        name: '广西',
        children: [
          {
            name: '桂林',
            children: [
              { name: '秀峰区', value: 20 },
              { name: '叠彩区', value: 130 },
              { name: '象山区', value: 140 },
              { name: '七星区', value: 10 },
            ],
          },
          { name: '南宁', value: 90 },
          { name: '柳州', value: 88 },
          { name: '防城港', value: 99 },
        ],
      },
      {
        name: '黑龙江',
        children: [
          { name: '哈尔滨', value: 54 },
          { name: '齐齐哈尔', value: 2 },
          { name: '牡丹江', value: 42 },
          { name: '大庆', value: 43 },
        ],
      },
      {
        name: '新疆',
        children: [
          { name: '乌鲁木齐', value: 1 },
          { name: '克拉玛依', value: 10 },
          { name: '吐鲁番', value: 23 },
          { name: '哈密', value: 43 },
        ],
      },
    ],
  }
  const width = 1666
  const height = 280
  const svg = select(myContainerDom)
    .append('svg')
    .classed('chart', true)
    .attr('width', width)
    .attr('height', height)
  const g = svg.append('g').attr('transform', 'translate(0, 20)')
  // 将原始的树结构数据,转换为Node(Object)数据
  const hierarchyData = hierarchy(data)

  console.log('——————d3.hierarchy(data)——————')
  console.log(hierarchyData)

  // 获取布局管理器
  const treeLayout = tree()
    .size([width, height - 30]) // 设置tree的大小.size表示树的宽度/高度,它们的大小直接影响到绘制的树的大小,因为绘制树的时候,是根据width和height来分配每个节点的位置,因此width和height越大,树越大;width和height越小,树越小:
    .separation((a, b) => {
      // 根据是否为同一父节点设置节点距离比例
      return a.parent === b.parent ? 1 : 2
    })
  console.log('——————treeLayout——————')
  console.log(treeLayout)

  // 将Node(Object)数据转换为方便d3画图的数据
  const nodesData = treeLayout(hierarchyData)
  console.log('——————nodesData——————')
  console.log(nodesData)

  // 画线
  const links = g
    .selectAll('.links')
    .data(nodesData.descendants().slice(1)) //nodesData.descendants()返回所有节点的数据,利于我们绑定数据,slcie(1)截取root后的全部节点,防止重绘
    .enter()
    .append('path') //用path画线
    .attr('fill', 'none')
    .attr('stroke', '#313131')
    .attr('stroke-width', 2)
    .attr('d', d => {
      const parent: HierarchyPointNode<unknown> =
        d.parent as HierarchyPointNode<unknown>
      return `
        M${d.x},${d.y}
        C${d.x},${(d.y + parent.y) / 2}
        ${parent.x},${(d.y + parent.y) / 2.5}
        ${parent.x},${parent.y}`
    })

  // 画圆
  //当一个节点中有多个子元素时(比如本例中有text和circle),我个人喜欢用g作为容器
  const nodes = g
    .selectAll('.node')
    .data(nodesData.descendants()) //同样是获得所有节点,便于数据绑定
    .enter()
    .append('g')
    .attr('transform', d => {
      return `translate(${d.x}, ${d.y})` //位移
    })
  //画圆
  nodes.append('circle').style('fill', '#c03027').attr('r', 10)
  //插入文字
  nodes
    .append('text')
    .attr('dx', '.9em')
    .text(d => {
      const originalData = d.data as any
      return originalData.name
    })
})
</script>

<template>
  <div class="myDiv">
    <h3>表格</h3>
    <div ref="myContainerRef"></div>
  </div>
</template>

<style lang="scss" scoped>
.myDiv {
  margin-left: 10px;
}
</style>

第二版

说明

  • 基于第一版,修改了连线的风格
  • 删除了tree().size(xxx)设置,增加了tree().nodeSize(xxx),由自己控制每个节点的宽高
  • 通过设置translate方式,解决设置了nodeSize之后,树图只展示了半边的问题。

参考文章

d3.js - 指定 nodeSize 时 d3 树的中心会发生变化

D3图表-树图的展开收起动画

效果

image.png

代码

<!--
TODO: 编写组件说明
@author pan
@date 2022-04-29
-->
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { select } from 'd3-selection'
import { hierarchy, HierarchyPointNode, tree } from 'd3-hierarchy'

const myContainerRef = ref<HTMLDivElement>()

onMounted(() => {
  const myContainerDom = myContainerRef.value as HTMLDivElement
  const data = {
    name: '中国',
    children: [
      {
        name: '浙江',
        children: [
          { name: '杭州', value: 100 },
          { name: '宁波', value: 200 },
          { name: '温州', value: 30 },
          { name: '绍兴', value: 50 },
        ],
      },
      {
        name: '广西',
        children: [
          {
            name: '桂林',
            children: [
              { name: '秀峰区', value: 20 },
              { name: '叠彩区', value: 130 },
              { name: '象山区', value: 140 },
              { name: '七星区', value: 10 },
            ],
          },
          { name: '南宁', value: 90 },
          { name: '柳州', value: 88 },
          { name: '防城港', value: 99 },
        ],
      },
      {
        name: '黑龙江',
        children: [
          { name: '哈尔滨', value: 54 },
          { name: '齐齐哈尔', value: 2 },
          { name: '牡丹江', value: 42 },
          { name: '大庆', value: 43 },
        ],
      },
      {
        name: '新疆',
        children: [
          { name: '乌鲁木齐', value: 1 },
          { name: '克拉玛依', value: 10 },
          { name: '吐鲁番', value: 23 },
          { name: '哈密', value: 43 },
        ],
      },
    ],
  }
  const width = 1666
  const height = 380
  const svg = select(myContainerDom)
    .append('svg')
    .classed('chart', true)
    .attr('width', width)
    .attr('height', height)
  const g = svg
    .append('g')
    .attr('transform', `translate(${width / 2}, ${height / 2 - 100})`)
  // 将原始的树结构数据,转换为Node(Object)数据
  const hierarchyData = hierarchy(data)

  console.log('——————d3.hierarchy(data)——————')
  console.log(hierarchyData)

  // 获取布局管理器
  const treeLayout = tree()
    // .size([width, height - 30]) //设置tree的大小
    .nodeSize([90, 60]) //设置tree的大小
    .separation((a, b) => {
      // 根据是否为同一父节点设置节点距离比例
      return a.parent === b.parent ? 1 : 2
    })
  console.log('——————treeLayout——————')
  console.log(treeLayout)

  // 将Node(Object)数据转换为方便d3画图的数据
  const nodesData = treeLayout(hierarchyData)
  console.log('——————nodesData——————')
  console.log(nodesData)

  // 画线
  const links = g
    .selectAll('.links')
    .data(nodesData.descendants().slice(1)) //nodesData.descendants()返回所有节点的数据,利于我们绑定数据,slcie(1)截取root后的全部节点,防止重绘
    .enter()
    .append('path') //用path画线
    .attr('fill', 'none')
    .attr('stroke', '#313131')
    .attr('stroke-width', 2)
    .attr('d', d => {
      const parent: HierarchyPointNode<unknown> =
        d.parent as HierarchyPointNode<unknown>
      return `
        M${parent.x},${parent.y}
        L${parent.x},${parent.y + 25}
        L${d.x},${parent.y + 25}
        L${d.x},${d.y - 20}`
    })

  // 画圆
  //当一个节点中有多个子元素时(比如本例中有text和circle),我个人喜欢用g作为容器
  const nodes = g
    .selectAll('.node')
    .data(nodesData.descendants()) //同样是获得所有节点,便于数据绑定
    .enter()
    .append('g')
    .attr('transform', d => {
      return `translate(${d.x}, ${d.y})` //位移
    })
  //画圆
  nodes.append('circle').style('fill', '#c03027').attr('r', 10)
  //插入文字
  nodes
    .append('text')
    .attr('dx', '.9em')
    .text(d => {
      const originalData = d.data as any
      return originalData.name
    })
})
</script>

<template>
  <div class="myDiv">
    <h3>表格</h3>
    <div ref="myContainerRef"></div>
  </div>
</template>

<style lang="scss" scoped>
.myDiv {
  margin-left: 10px;
}
</style>

第三版

说明

基于第二版,增加如下控制

  • 连线控制:只在根节点与直属的第一个子节点和最后一个子节点之间画连线(代码详见:.attr('stroke-width', (d: any) => {...})
  • 连线风格控制:连线的转折点不再是纯粹的直角(代码详见:.attr('d', d => {...}))

效果

image.png

代码

<!--
TODO: 编写组件说明
@author pan
@date 2022-04-29
-->
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { select } from 'd3-selection'
import { hierarchy, HierarchyPointNode, tree } from 'd3-hierarchy'

const myContainerRef = ref<HTMLDivElement>()

onMounted(() => {
  const myContainerDom = myContainerRef.value as HTMLDivElement
  const data = {
    name: '中国',
    children: [
      {
        name: '浙江',
        children: [
          { name: '杭州', value: 100 },
          { name: '宁波', value: 200 },
          { name: '温州', value: 30 },
          { name: '绍兴', value: 50 },
        ],
      },
      {
        name: '广西',
        children: [
          {
            name: '桂林',
            children: [
              { name: '秀峰区', value: 20 },
              { name: '叠彩区', value: 130 },
              { name: '象山区', value: 140 },
              { name: '七星区', value: 10 },
            ],
          },
          { name: '南宁', value: 90 },
          { name: '柳州', value: 88 },
          { name: '防城港', value: 99 },
        ],
      },
      {
        name: '黑龙江',
        children: [
          { name: '哈尔滨', value: 54 },
          { name: '齐齐哈尔', value: 2 },
          { name: '牡丹江', value: 42 },
          { name: '大庆', value: 43 },
        ],
      },
      {
        name: '新疆',
        children: [
          { name: '乌鲁木齐', value: 1 },
          { name: '克拉玛依', value: 10 },
          { name: '吐鲁番', value: 23 },
          { name: '哈密', value: 43 },
        ],
      },
    ],
  }
  const width = 1666
  const height = 380
  const svg = select(myContainerDom)
    .append('svg')
    .classed('chart', true)
    .attr('width', width)
    .attr('height', height)
  const g = svg
    .append('g')
    .attr('transform', `translate(${width / 2}, ${height / 2 - 100})`)
  // 将原始的树结构数据,转换为Node(Object)数据
  const hierarchyData = hierarchy(data)

  console.log('——————d3.hierarchy(data)——————')
  console.log(hierarchyData)

  // 获取布局管理器
  const treeLayout = tree()
    // .size([width, height - 30]) //设置tree的大小
    .nodeSize([90, 60]) //设置tree的大小
    .separation((a, b) => {
      // 根据是否为同一父节点设置节点距离比例
      return a.parent === b.parent ? 1 : 2
    })

  // 将Node(Object)数据转换为方便d3画图的数据
  const nodesData = treeLayout(hierarchyData)
  console.log('——————nodesData——————')
  console.log(nodesData)

  // 画线
  const links = g
    .selectAll('.links')
    .data(nodesData.descendants().slice(1)) //nodesData.descendants()返回所有节点的数据,利于我们绑定数据,slcie(1)截取root后的全部节点,防止重绘
    .enter()
    .append('path') //用path画线
    .attr('fill', 'none')
    .attr('stroke', '#313131')
    .attr('stroke-width', (d: any) => {
      // 这里是控制节点与节点之间连线的粗细(返回0表示连线不可见),这段逻辑是控制只在父节点和子中的第一个和最后一个节点画连线
      if (
        d.parent.children &&
        (d === d.parent.children[0] ||
          d === d.parent.children[d.parent.children.length - 1])
      ) {
        return 2
      } else {
        return 0
      }
    })
    .attr('d', d => {
      const parent: HierarchyPointNode<unknown> =
        d.parent as HierarchyPointNode<unknown>
      // 这里的起点是父节点的x,y位置,终点是子节点的x,y位置
      console.log(d, parent.x, d.x)
      if (parent.x > d.x) {
        // 这里是根节点到左子节点的连线
        // 从父节点开始的第一条斜线的起点:L${parent.x},${parent.y + 20}
        // 从父节点开始的第一条斜线的终点:L${parent.x - 10},${parent.y + 25}
        // 从父节点开始的第二条斜线的起点:L${d.x + 5},${parent.y + 25}
        // 从父节点开始的第二条斜线的终点:L${d.x},${d.y - 30}
        return `
        M${parent.x},${parent.y}
        L${parent.x},${parent.y + 20}
        L${parent.x - 10},${parent.y + 25}
        L${d.x + 5},${parent.y + 25}
        L${d.x},${d.y - 30}
        L${d.x},${d.y - 15}`
      } else {
        // 这里是根节点到右子节点的连线
        // 从父节点开始的第一条斜线的起点:L${parent.x},${parent.y + 20}
        // 从父节点开始的第一条斜线的终点:L${parent.x + 10},${parent.y + 25}
        // 从父节点开始的第二条斜线的起点:L${d.x - 5},${parent.y + 25}
        // 从父节点开始的第二条斜线的终点:L${d.x},${parent.y + 30}
        return `
        M${parent.x},${parent.y}
        L${parent.x},${parent.y + 20}
        L${parent.x + 10},${parent.y + 25}
        L${d.x - 5},${parent.y + 25}
        L${d.x},${parent.y + 30}
        L${d.x},${d.y - 15}`
      }
    })

  // 画圆
  //当一个节点中有多个子元素时(比如本例中有text和circle),我个人喜欢用g作为容器
  const nodes = g
    .selectAll('.node')
    .data(nodesData.descendants()) //同样是获得所有节点,便于数据绑定
    .enter()
    .append('g')
    .attr('transform', d => {
      return `translate(${d.x}, ${d.y})` //位移
    })
  //画圆
  nodes.append('circle').style('fill', '#c03027').attr('r', 10)
  //插入文字
  nodes
    .append('text')
    .attr('dx', '.9em')
    .text(d => {
      const originalData = d.data as any
      return originalData.name
    })
})
</script>

<template>
  <div class="myDiv">
    <h3>表格</h3>
    <div ref="myContainerRef"></div>
  </div>
</template>

<style lang="scss" scoped>
.myDiv {
  margin-left: 10px;
}
</style>

第四版

说明

  • 线风格改变:使用贝塞尔曲线,使线段的转折点更平滑
  • 放弃transform方式使树居中显示,改为使用viewBox控制树居中显示(因为原来transform方式在加入了svg的拖动,缩放功能之后有问题,viewBox方式下则无问题)
  • 加入svg整体的缩放和拖动功能

如果还不理解贝塞尔曲线在svg中实际怎么设置的请看我的这篇文章:d3中关于path中实际使用贝塞尔曲线的控制点解惑

参考文章

javascript - 如何根据内容设置 SVG(由 D3.js 绘制)的高度?

d3.js - 缩放和居中 D3-Graphviz Graph

效果

show-07.gif

代码

<!--
TODO: 编写组件说明
@author pan
@date 2022-04-29
-->
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { select } from 'd3-selection'
import { hierarchy, HierarchyPointNode, tree } from 'd3-hierarchy'
import { zoom } from 'd3-zoom'

const myContainerRef = ref<HTMLDivElement>()

onMounted(() => {
  const myContainerDom = myContainerRef.value as HTMLDivElement
  const data = {
    name: '中国',
    children: [
      {
        name: '浙江',
        children: [
          { name: '杭州', value: 100 },
          { name: '宁波', value: 200 },
          { name: '温州', value: 30 },
          { name: '绍兴', value: 50 },
        ],
      },
      {
        name: '广西',
        children: [
          {
            name: '桂林',
            children: [
              { name: '秀峰区', value: 20 },
              { name: '叠彩区', value: 130 },
              { name: '象山区', value: 140 },
              { name: '七星区', value: 10 },
            ],
          },
          { name: '南宁', value: 90 },
          { name: '柳州', value: 88 },
          { name: '防城港', value: 99 },
        ],
      },
      {
        name: '黑龙江',
        children: [
          { name: '哈尔滨', value: 54 },
          { name: '齐齐哈尔', value: 2 },
          { name: '牡丹江', value: 42 },
          { name: '大庆', value: 43 },
        ],
      },
      {
        name: '新疆',
        children: [
          { name: '乌鲁木齐', value: 1 },
          { name: '克拉玛依', value: 10 },
          { name: '吐鲁番', value: 23 },
          { name: '哈密', value: 43 },
        ],
      },
    ],
  }
  const width = 1666
  const height = 380
  const svg = select(myContainerDom)
    .append('svg')
    .classed('chart', true)
    .attr('width', width)
    .attr('height', height)
    .attr('viewBox', `${-(width / 2)} ${-(height / 2)} ${width} ${height}`)
  const g = svg.append('g')
  // .attr('transform', `translate(${width / 2}, ${height / 2 - 100})`)
  svg.call(
    //@ts-ignore
    zoom().on('zoom', ev => {
      g.attr('transform', ev.transform)
    })
  )
  // 将原始的树结构数据,转换为Node(Object)数据
  const hierarchyData = hierarchy(data)

  console.log('——————d3.hierarchy(data)——————')
  console.log(hierarchyData)

  // 获取布局管理器
  const treeLayout = tree()
    // .size([width, height - 30]) //设置tree的大小
    .nodeSize([90, 60]) //设置tree的大小
    .separation((a, b) => {
      // 根据是否为同一父节点设置节点距离比例
      return a.parent === b.parent ? 1 : 2
    })

  // 将Node(Object)数据转换为方便d3画图的数据
  const nodesData = treeLayout(hierarchyData)
  console.log('——————nodesData——————')
  console.log(nodesData)

  // 画线
  const links = g
    .selectAll('.links')
    .data(nodesData.descendants().slice(1)) //nodesData.descendants()返回所有节点的数据,利于我们绑定数据,slcie(1)截取root后的全部节点,防止重绘
    .enter()
    .append('path') //用path画线
    .attr('fill', 'none')
    .attr('stroke', '#313131')
    .attr('stroke-width', (d: any) => {
      // 这里是控制节点与节点之间连线的粗细(返回0表示连线不可见),这段逻辑是控制只在父节点和子中的第一个和最后一个节点画连线
      if (
        d.parent.children &&
        (d === d.parent.children[0] ||
          d === d.parent.children[d.parent.children.length - 1])
      ) {
        return 2
      } else {
        return 0
      }
    })
    .attr('d', d => {
      //通过三次贝塞尔曲线设置连线的弯曲程度。M:move to,即到控制点 C后设置两个控制点及终点
      const parent: HierarchyPointNode<unknown> =
        d.parent as HierarchyPointNode<unknown>
      // 这里线条的起点是父节点的x,y位置,终点是子节点的x,y位置
      if (parent.x > d.x) {
        // 这里是根节点到左子节点的连线
        // 从父节点开始的第一条斜线的起点:L${parent.x},${parent.y + 20}
        // 从父节点开始的第一条斜线的终点(通过贝塞尔曲线使得转折点是弯曲的):Q${parent.x - 5} ${parent.y + 25} ${parent.x - 10} ${parent.y + 25}
        // 从父节点开始的第二条斜线的起点:L${d.x + 5},${parent.y + 25}
        // 从父节点开始的第二条斜线的终点(通过贝塞尔曲线使得转折点是弯曲的):Q${d.x} ${d.y - 35} ${d.x} ${d.y - 30}
        return `
        M${parent.x},${parent.y}
        L${parent.x},${parent.y + 20}
        Q${parent.x - 5} ${parent.y + 25} ${parent.x - 10} ${parent.y + 25}
        L${d.x + 5},${parent.y + 25}
        Q${d.x} ${d.y - 35} ${d.x} ${d.y - 30}
        L${d.x},${d.y - 15}`
      } else {
        // 这里是根节点到右子节点的连线
        // 从父节点开始的第一条斜线的起点:L${parent.x},${parent.y + 20}
        // 从父节点开始的第一条斜线的终点(通过贝塞尔曲线使得转折点是弯曲的):Q${parent.x + 5} ${parent.y + 25} ${parent.x + 10} ${parent.y + 25}
        // 从父节点开始的第二条斜线的起点:L${d.x - 5},${parent.y + 25}
        // 从父节点开始的第二条斜线的终点(通过贝塞尔曲线使得转折点是弯曲的):Q${d.x} ${parent.y + 25} ${d.x} ${parent.y + 30}
        return `
        M${parent.x},${parent.y}
        L${parent.x},${parent.y + 20}
        Q${parent.x + 5} ${parent.y + 25} ${parent.x + 10} ${parent.y + 25}
        L${d.x - 5},${parent.y + 25}
        Q${d.x} ${parent.y + 25} ${d.x} ${parent.y + 30}
        L${d.x},${d.y - 15}`
      }
    })

  // 画圆
  //当一个节点中有多个子元素时(比如本例中有text和circle),我个人喜欢用g作为容器
  const nodes = g
    .selectAll('.node')
    .data(nodesData.descendants()) //同样是获得所有节点,便于数据绑定
    .enter()
    .append('g')
    .attr('transform', d => {
      return `translate(${d.x}, ${d.y})` //位移
    })
  //画圆
  nodes.append('circle').style('fill', '#c03027').attr('r', 10)
  //插入文字
  nodes
    .append('text')
    .attr('dx', '.9em')
    .text(d => {
      const originalData = d.data as any
      return originalData.name
    })
})
</script>

<template>
  <div class="myDiv">
    <h3>表格</h3>
    <div ref="myContainerRef"></div>
  </div>
</template>

<style lang="scss" scoped>
.myDiv {
  margin-left: 10px;
}
</style>