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

·  阅读 559

树图是很常用的图表,它有结点和子节点的结构。如果一个结点的子节点太多,我们通常会把它们先隐藏起来,在点击父节点的时候展开显示。为了美观和流畅,我们可以加上展开收起的动画。

用 D3 构建树图非常简单,只需要类似:

{
  name: '0',
  children: [
    {
      name: '1',
      children: [
        {
          name: '1-0'
        },
        {
          name: '1-1'
        }
      ]
    }
    {
      name: '2',
      children: [
        {
          name: '2-0'
        },
        {
          name: '2-1'
        }
      ]
    }
  ]
}
复制代码

这样的数据结构,然后:

const tree = D3.tree().nodeSize([Node.height, Node.width]) // 创建树构造器
const data = D3.hierarchy(Data) // 创建树型数据结构

let root = tree(data)
复制代码

tree是树型数据构造器,它是一个函数,接受上述的数据作为参数,返回树的根结点。子节点的位置会根据其在树中的位置和节点的大小(Node.heightNode.width,因为 D3 中默认树的方向是根在上叶在下,而我们需要的是根在中间左,叶在左右的图表,所以设定子节点大小时宽度和高度的参数位置需要交换)计算好了。通过树型数据构造器得到根结点root

假设在画图前,我们已经准备好了 svg 元素和用于容纳结点和连线的 g 元素:$linkGroup$nodeGroup

因为有点击改变树结构(展开和收起)的操作,我们可以预见到需要重复绘制,所以我们将绘制这个过程写成一个函数,在初始化和点击父节点时调用它。

function draw() {
  // ...
}
复制代码

接下来填充这个函数的内容。

结点

首先通过根结点的descendants方法获得所有结点。

let nodes = root.descendants()
复制代码

绑定数据

接着绑定数据:

let $nodes = $nodeGroup.selectAll('.node').data(nodes, d => d.data.name)
复制代码

需要注意的是为了防止某个数据的结点被复用,进而二次绑定为其它数据,造成动画的混乱,我们这里指定元素和数据绑定的 key,这样结点在绑定数据时若 key 存在,则只会绑定以前的数据,这里我们使用数据中的name成员。

创建元素

使用新版本的join方法可以简化,创建、更新和删除元素的过程:

$nodes
  .join(
    enter => {
      let $gs = enter.append('g')
      // 创建矩形和文本的过程省略

      return $gs
    },
    update => update,
    exit => exit.remove()
  )
  .attr('class', 'node')
复制代码

定位

这样,代表结点的 g 元素就绘制到屏幕上了,但此时它们还集中在[0, 0]坐标的位置,我们根据其结点数据中xy成员来设置 g 元素的transform属性来定位。

$nodes.attr('transform', d => `translate(${d.x}, ${d.y})`)
复制代码

连线

通过根结点的links方法获取所有连线的数据:

let links = root.links()
复制代码

links是包含连线的起点和终点的对象的数组:{source, target}[]

绑定数据

let $links = $linkGroup.selectAll('.link').data(links, d => d.target.data.name)
复制代码

这里用终点结点的name成员作为 key。

创建元素

$links.join(
  enter => enter.append('path').attr('class', 'link').attr('fill', 'none').attr('stroke', 'gray'),
  update => update,
  exit => exit.remove()
)
复制代码

折线

$links.attr('d', d => {
  let s = d.source
  let t = d.target
  let mx = (s.x + t.x) / 2

  return `M ${s.x},${s.y} L ${mx},${s.y} L ${mx},${t.y} L ${t.x},${t.y}`
})
复制代码

虽然 D3 有linkVerticallinkHorizontal连线构造器,但是他们构造的连线都是曲线,我们这里采用在两点的 X 轴中点出弯折的折线来作为连线。

结点点击

当点击某些结点时,可以收起其子元素,再次点击时可以展开子元素。我们需要在父元素结点上绑定点击事件处理函数:

$nodes.on('click', handle_node_click)

/**
 * @name 处理结点点击
 * @param {Object} ev 事件
 * @param {Object} d 数据
 */
function handle_node_click(ev, d) {
  if (d.depth !== 0) {
    if (d.children) {
      d._children = d.children
      d.children = undefined

      draw() // 再次绘制
    } else if (d._children) {
      d.children = d._children

      draw()
    }
  }
}
复制代码

处理的方式很简单,就是如果这个结点有children成员,那么就将它暂存到_children中,并删除children成员,这样 D3 在处理数据时就认为这个结点没有子结点;如果这个结点没有children成员但是有_children,那么就将_children成员重新赋值给children成员,子结点就重新出现了。

动画

到目前位置,树图子结点的展开和收起还没有动画,要呈现动画,最简单的方式是使用 D3 的transition方法。

结点

对于结点,我们在设置位置前调用:

$nodes.transition().attr('transform', d => `translate(${d.x}, ${d.y})`)
复制代码

但是此时结点不是从父节点处出现,而是根结点:

因为结点在未设置transform前,默认位置是[0, 0]。

起始位置

为了让结点在父节点处出现,我们首先记下父节点的位置,然后在动画前先将结点移动到这个位置。由于这个步骤没有动画,所以看起来就像是结点就从那里出现的。

保存父节点的操作在点击父节点时完成:

function handle_node_click(ev, d) {
  d.sourceX = d.x // 保存自身原本位置
  d.sourceY = d.y

  if (d.depth !== 0) {
    if (d.children) {
      d._children = d.children
      d.children = undefined

      draw()
    } else if (d._children) {
      for (let a of d._children) {
        a.originX = a.parent.x // 保存父节点原本位置
        a.originY = a.parent.y
      }

      d.children = d._children

      draw()
    }
  }
}
复制代码

同时,在结点的动画前,我们设置结点到父节点原本的位置上:

$nodes
  .filter(a => a.originX !== undefined && a.originY !== undefined)
  .attr('transform', d => {
    let x, y

    if (d.originX) {
      x = d.originX
      delete d.originX
    } else {
      x = d.x
    }
    if (d.originY) {
      y = d.originY
      delete d.originY
    } else {
      y = d.y
    }

    return `translate(${x}, ${y})`
  })
复制代码

注意这里只需要设置那些有originXoriginY的结点,否则非更新的结点会失去动画。同时,在一次更行后要删除originXoriginY,否在其它结点展开收起时,这组结点会也会进行动画,这很明显是多余且错误的。

这样,结点展开动画的起始位置就在父节点上了。

结束位置

对于收起动画也一样,在删除元素前,让元素移动到父元素的位置就行。

exit
  .transition()
  .attr('transform', d => `translate(${d.parent.x},${d.parent.y})`)
  .remove()
复制代码

连线

对于连线的动画,它的问题是起始位置在父结点的新位置上。

解决办法和解决结点动画起始位置的方法类似,记录下父结点原位置即可。即在handle_node_click函数中的这一部分:

d.sourceX = d.x
d.sourceY = d.y
复制代码

然后在绘制连线时,在动画前将连线的起点定位在父结点原位置即可:

enter.attr('d', d => {
  let s = d.source
  let origin = `${s.sourceX || s.x},${s.sourceY || s.y}`

  return `M ${origin} L ${origin} L ${origin} L ${origin}`
})
复制代码

源码

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>

    <style>
      body {
        overflow: hidden;
        padding: 0;
        margin: 0;
      }
      #app {
        overflow: hidden;
        position: relative;
        width: 100vw;
        height: 100vh;
      }
    </style>
    <script src="./script.js" defer type="module"></script>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>
复制代码

script.js

import Data from './data.js'
import * as D3 from 'https://cdn.skypack.dev/d3@7'

const Node = {
  width: 200,
  height: 60,
  background: 'rgb(0, 139, 248)',
  r: 8,
  color: 'white'
}
const CenterBackground = 'orange'
const ParentBackground = 'darkblue'
const TransitionDuration = 1000

/* svg */

const $svg = D3.create('svg')
$svg.attr('width', '100%')
$svg.attr('height', '100%')
$svg.attr('viewBox', '-500 -500 1000 1000')

const $wrap = $svg.append('g')
$svg.call(
  D3.zoom().on('zoom', ev => {
    $wrap.attr('transform', ev.transform)
  })
)
const $linkGroup = $wrap.append('g').attr('class', 'link-group')
const $nodeGroup = $wrap.append('g').attr('class', 'node-group')

document.querySelector('#app').appendChild($svg.node())

/* tree */

const tree = D3.tree().nodeSize([Node.height, Node.width])
const data = D3.hierarchy(Data)

/* draw */

/**
 * @name 绘制
 * @param {Boolean} init 第一次
 */
function draw(init = false) {
  let root = tree(data)

  let nodes = root.descendants()
  let left = root.children.filter(a => /^(1|2)/.test(a.data.name))
  let right = root.children.filter(a => /^(3|4)/.test(a.data.name))

  nodes.forEach(a => ([a.x, a.y] = [a.y, a.x])) // 需要旋转90度

  let leftMiddleOffset = (left[0].y + left[1].y) / 2
  left.forEach(a => {
    a.descendants().forEach(b => {
      b.x = -b.x
      b.y -= leftMiddleOffset
    })
  })
  let rightMiddleOffset = (right[0].y + right[1].y) / 2
  right.forEach(a => {
    a.descendants().forEach(b => {
      b.y -= rightMiddleOffset
    })
  })

  let $nodes = $nodeGroup
    .selectAll('.node')
    .data(nodes, d => d.data.name)
    .join(
      enter => {
        let $gs = enter.append('g')
        $gs
          .append('rect')
          .attr('width', Node.width / 2)
          .attr('height', Node.height * 0.66)
          .attr('transform', `translate(${-Node.width / 4}, ${-Node.height * 0.33})`)
          .attr('fill', d => {
            if (d.depth === 0) {
              return CenterBackground
            } else if (d.children || d._children) {
              return ParentBackground
            } else {
              return Node.background
            }
          })
          .attr('rx', Node.r)
          .attr('ry', Node.r)
        $gs
          .append('text')
          .text(d => d.data.name)
          .style('font-size', '20px')
          .attr('fill', Node.color)
          .attr('text-anchor', 'middle')
          .attr('y', Node.height * 0.16)

        return $gs
      },
      update => update,
      exit => {
        exit
          .transition()
          .duration(init ? 0 : TransitionDuration)
          .attr('opacity', 0)
          .attr('transform', d => `translate(${d.parent.x},${d.parent.y})`)
          .remove()
      }
    )
    .attr('class', 'node')
    .on('click', handle_node_click)

  $nodes
    .filter(a => a.originX !== undefined && a.originY !== undefined)
    .attr('opacity', 0)
    .attr('transform', d => {
      let x, y

      if (d.originX) {
        x = d.originX
        delete d.originX
      } else {
        x = d.x
      }
      if (d.originY) {
        y = d.originY
        delete d.originY
      } else {
        y = d.y
      }

      return `translate(${x}, ${y})`
    })

  $nodes
    .transition()
    .duration(init ? 0 : TransitionDuration)
    .attr('opacity', 1)
    .attr('transform', d => `translate(${d.x}, ${d.y})`)

  let links = root.links()

  $linkGroup
    .selectAll('.link')
    .data(links, d => d.target.data.name)
    .join(
      enter =>
        enter
          .append('path')
          .attr('class', 'link')
          .attr('fill', 'none')
          .attr('stroke', 'gray')
          .attr('d', d => {
            let s = d.source
            let origin = `${s.sourceX || s.x},${s.sourceY || s.y}`

            return `M ${origin} L ${origin} L ${origin} L ${origin}`
          }),
      update => update,
      exit =>
        exit
          .transition()
          .duration(init ? 0 : TransitionDuration)
          .attr('d', d => {
            let s = d.source
            let origin = `${s.x},${s.y}`

            return `M ${origin} L ${origin} L ${origin} L ${origin}`
          })
          .remove()
    )
    .transition()
    .duration(init ? 0 : TransitionDuration)
    .attr('d', d => {
      let s = d.source
      let t = d.target
      let mx = (s.x + t.x) / 2

      return `M ${s.x},${s.y} L ${mx},${s.y} L ${mx},${t.y} L ${t.x},${t.y}`
    })
}
/**
 * @name 处理结点点击
 * @param {Object} ev 事件
 * @param {Object} d 数据
 */
function handle_node_click(ev, d) {
  // d.sourceX = d.x
  // d.sourceY = d.y

  if (d.depth !== 0) {
    if (d.children) {
      d._children = d.children
      d.children = undefined

      draw()
    } else if (d._children) {
      for (let a of d._children) {
        a.originX = a.parent.x
        a.originY = a.parent.y
      }

      d.children = d._children

      draw()
    }
  }
}

draw(true)
复制代码

data.js

/**
 * @name 数据
 */

const data = {
  name: '0',
  children: [
    {
      name: '1',
      children: [
        {
          name: '1-0'
        },
        {
          name: '1-1'
        },
        {
          name: '1-2'
        },
        {
          name: '1-3'
        },
        {
          name: '1-4'
        },
        {
          name: '1-5'
        },
        {
          name: '1-6'
        },
        {
          name: '1-7'
        },
        {
          name: '1-9'
        },
        {
          name: '1-10'
        }
      ]
    },
    {
      name: '2',
      children: [
        {
          name: '2-0'
        },
        {
          name: '2-1'
        },
        {
          name: '2-2'
        },
        {
          name: '2-3'
        },
        {
          name: '2-4'
        }
      ]
    },
    {
      name: '3',
      children: [
        {
          name: '3-0'
        },
        {
          name: '3-1'
        },
        {
          name: '3-2'
        }
      ]
    },
    {
      name: '4',
      children: [
        {
          name: '4-0'
        },
        {
          name: '4-1'
        },
        {
          name: '4-2'
        },
        {
          name: '4-3'
        },
        {
          name: '4-4'
        },
        {
          name: '4-5'
        },
        {
          name: '4-6'
        },
        {
          name: '4-7'
        }
      ]
    }
  ]
}

export default data
复制代码
分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改