d3.js 入门学习记录(十) 力图

1,600 阅读9分钟

引力和相互作用力

先上代码:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
<div class="control-group">
    <button onclick="noForce()">no force</button>
    <button onclick="repulsion()">repulsion</button>
    <button onclick="gravity()">gravity</button>
    <button onclick="positioningWithGravity()">positioning with gravity</button>
    <button onclick="positioningWithRepulsion()">positioning with repulsion</button>
</div>
<script src="../d3.js"></script>
<script>
    const width = 1280,
          height = 800,
          r = 4.5,
          nodes = [],
          colors = d3.scaleOrdinal(d3.schemeCategory10),
          force = d3.forceSimulation()
            .velocityDecay(0.8)
            .alphaDecay(0)
            .force('collision', d3.forceCollide(r + 0.5).strength(1))

    const svg = d3.select('body')
        .append('svg')
            .attr('width', width)
            .attr('height', height)

    force.on('tick', function () {
        svg.selectAll('circle')
          .attr('cx', d => d.x)
          .attr('cy', d => d.y)
    })

    svg.on('mousemove', function () {
        const point = d3.mouse(this),
              node = {x: point[0], y: point[1]}

        svg.append('circle')
          .data([node])
          .attr('class', 'node')
          .attr('cx', d => d.x)
          .attr('cy', d => d.y)
          .attr('r', 1e-6)
          .style('fill', d => colors(Math.round(Math.random() * 10)))
          .transition()
          .attr('r', r)
          .transition().delay(10000)
          .attr('r', 1e-6)
          .on('end', function () {
                nodes.shift()
                force.nodes(nodes)
          })
          .remove()

        nodes.push(node)
        force.nodes(nodes)
    })

    function noForce() {
        force.force('charge', null)
        force.force('x', null)
        force.force('y', null)
        force.restart()
    }

    function repulsion() {
        force.force('charge', d3.forceManyBody().strength(-10))
        force.force('x', null)
        force.force('y', null)
        force.restart()
    }

    function gravity() {
      force.force('charge', d3.forceManyBody().strength(1))
      force.force('x', null)
      force.force('y', null)
      force.restart()
    }

    function positioningWithGravity() {
        force.force('charge', d3.forceManyBody().strength(1))
        force.force('x', d3.forceX().x(width / 2))
        force.force('y', d3.forceY().y(height / 2))
        force.restart()
    }

    function positioningWithRepulsion() {
      force.force('charge', d3.forceManyBody().strength(-10))
      force.force('x', d3.forceX().x(width / 2))
      force.force('y', d3.forceY().y(height / 2))
      force.restart()
    }
</script>
</body>
</html>

效果如下:

可以看见我们生成的小圆点互相之间有了引力和斥力,d3 内置的 force 模块帮助我们在 web 页面中通过算法模拟实现了物理的力学效果。

d3 提供了许多单力给我们使用,但是在实际使用中,我们通常是使用多种效果复合的仿真力的(就像实例中的粒子有碰撞,有引力,还有向中心力),我们就要借助于 forceSimulation 来帮助我们实现仿真力,其api如下:

  1. d3.forceSimulation - 创建一个新的力学仿真.
  2. simulation.restart - 重新启动仿真的定时器.
  3. simulation.stop - 停止仿真的定时器.
  4. simulation.tick - 进行一步仿真模拟.
  5. simulation.nodes - 设置仿真的节点.
  6. simulation.alpha - 设置当前的 alpha 值.
  7. simulation.alphaMin - 设置最小 alpha 阈值.
  8. simulation.alphaDecay - 设置 alpha 衰减率.
  9. simulation.alphaTarget - 设置目标 alpha 值.
  10. simulation.velocityDecay - 设置速度衰减率. 1 对应于无摩擦环境, 0 对应冻结所有粒子.
  11. simulation.force - 添加或移除一个力模型.
  12. simulation.find - 根据指定的位置找出最近的节点.
  13. simulation.on - 添加或移除事件监听器.

在示例中,我们就创建了一个仿真力:

force = d3.forceSimulation()
    .velocityDecay(0.8)
    .alphaDecay(0)
    .force('collision', d3.forceCollide(r + 0.5).strength(1))

我们将速度衰减定为 0.8,alpha 衰减定为 0 (力一直持续下去,便于 demo 观察),然后我们使用 force 方法给仿真力集合添加各种力,这里初始化时添加了一个碰撞力,让粒子具有实际的体积。

随后我们要指定力的每一个 tick 中对粒子的处理:

force.on('tick', function () {
    svg.selectAll('circle')
      .attr('cx', d => d.x)
      .attr('cy', d => d.y)
})

然后通过 force.nodes(nodes) 给所有的节点数据应用力。

在变换力的函数中,我们也是通过 force 方法添加原力,然后再 restart,forceManyBody返回一个作用力,strength 的正负值决定了是引力还是斥力。

简单的力如下:

d3.forceCollide - 创建一个圆形区域的碰撞检测力模型.

collide.radius - 设置碰撞半径.

collide.strength - 设置碰撞检测力模型的强度. [0, 1], 默认 0.7

collide.iterations - 设置迭代次数, 数值越大,效果越优,但是会加大消耗, 默认为 1

d3.forceX - 创建一个 x -方向的一维作用力.

x.strength - 设置力强度. [0, 1], 默认 0.1

x.x - 设置目标 x -坐标.

d3.forceY - 创建一个 y -方向的一维作用力.

y.strength - 设置力强度. [0, 1], 默认 0.1

y.y - 设置目标 y -坐标.

d3.forceCenter - 创建一个中心作用力.

center.x - 设置中心作用力的 x -坐标.

center.y - 设置中心作用力的 y -坐标.

d3.forceManyBody - 创建一个电荷作用力模型.

manyBody.strength - 设置电荷力模型的强度,正值则表示节点之间相互吸引,负值表示节点之间相互排斥,默认-30

manyBody.theta - 设置 Barnes–Hut 算法的精度.

manyBody.distanceMin - 限制节点之间的最小距离.

manyBody.distanceMax - 限制节点之间的最大距离.

d3.forceRadial - 创建一个环形布局的作用力.

radial.strength - 设置力强度. [0, 1] 默认 0.1

radial.radius - 设置目标半径.

radial.x - 设置环形作用力的目标中心 x -坐标.

radial.y - 设置环形作用力的目标中心 y -坐标.

d3.forceLink - 创建一个 link 作用力.

link.links - 设置弹簧作用力的边.

link.id - 设置边元素中节点的查找方式是索引还是 id 字符串.

link.distance - 设置 link 的距离.

link.strength - 设置 link 的强度.

link.iterations - 设置迭代次数.

另外,力布局节点对象的属性如下:

index:节点数组中的索引值

x:当前节点位置的 x 坐标

y:当前节点位置的 y 坐标

vx:节点当前在 x 轴上的速度

vy:节点当前在 y 轴上的速度

fx:节点固定的 x 位置

fy:节点固定的 y 位置

使用连接约束

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        .line {
            fill: none;
            stroke: steelblue;
            stroke-width: 2;
        }

    </style>
</head>
<body>
<script src="../d3.js"></script>
<script>
    const width = 1280,
          height = 800,
          r = 4.5,
          nodes = [],
          links = [],
          force = d3.forceSimulation()
            .velocityDecay(0.5)
            .alphaDecay(0)
            .force('collision', d3.forceCollide(r + 0.5).strength(1))
            .force('charge', d3.forceManyBody().strength(-50).distanceMax(height / 10))

    const svg = d3.select('body').append('svg')
      .attr('width', width)
      .attr('height', height)

    force.on('tick', function () {
        svg.selectAll('circle')
          .attr('cx', d => boundX(d.x))
          .attr('cy', d => boundY(d.y))

        svg.selectAll('line')
          .attr('x1', d => boundX(d.source.x))
          .attr('y1', d => boundY(d.source.y))
          .attr('x2', d => boundX(d.target.x))
          .attr('y2', d => boundY(d.target.y))
    })

    function boundX(x) {
        return x > r ? (x < width - r ? x : width - r) : r
    }

    function boundY(y) {
        return y > r ? (y < height - r ? y : height - r) : r
    }

    function offset() {
        return Math.random() * 10
    }

    function createNodes(point) {
        const numberOfNodes = Math.round(Math.random() * 10),
              newNodes = []

        for (let i  = 0; i < numberOfNodes; i++) {
            newNodes.push({
                x: point[0] + offset(),
                y: point[1] + offset()
            })
        }

        newNodes.forEach(p => nodes.push(p))

        return newNodes
    }

    function createLinks(nodes) {
        const newLinks = []

        for (let i = 0; i < nodes.length; i++) {
            if (i === nodes.length - 1) {
                newLinks.push({
                    source: nodes[i],
                    target: nodes[0]
                })
            } else {
                newLinks.push({
                    source: nodes[i],
                    target: nodes[i + 1]
                })
            }
        }

        newLinks.forEach(l => links.push(l))

        return newLinks
    }

    svg.on('click', function () {
        const point = d3.mouse(this),
              newNodes = createNodes(point),
              newLinks = createLinks(newNodes)

        newNodes.forEach(node => {
            svg.append('circle')
                .data([node])
                    .classed('node', true)
                    .attr('cx', d => d.x)
                    .attr('cy', d => d.y)
                    .attr('r', 1e-6)
                    .call(d3.drag()
                        .on('start', d => {
                            d.fx = d.x
                            d.fy = d.y
                        })
                        .on('drag', d => {
                            d.fx = d3.event.x
                            d.fy = d3.event.y
                        })
                        .on('end', d => {
                            d.fx = null
                            d.fy = null
                        }))
                .transition()
                    .attr('r', 7)
                .transition()
                    .delay(10000)
                    .attr('r', 1e-6)
                    .on('end', d => nodes.shift())
                    .remove()
        })

        newLinks.forEach(link => {
            svg.append('line')
                .data([link])
                    .classed('line', true)
                    .attr('x1', d => d.source.x)
                    .attr('y1', d => d.source.y)
                    .attr('x2', d => d.target.x)
                    .attr('y2', d => d.target.y)
                .transition().delay(10000)
                    .style('stroke-opacity', 1e-6)
                    .on('end', d => links.shift())
                    .remove()
        })

        force.nodes(nodes)
        force.force('link', d3.forceLink(links).strength(1).distance(20))

        force.restart()
    })

</script>
</body>
</html>

效果如下:

我们在每次点击时都根据点击的位置生成个数随机的圆点和线条,根据圆点数据生成的线条数据是首尾相连的(线条数据中的数据和节点数据是相同引用地址的), 随后我们给仿真力添加 link 力,然后应用到节点上。在 tick 中我们需要添加额外的对线条的渲染处理(限制了不超出svg区域):

svg.selectAll('line')
  .attr('x1', d => boundX(d.source.x))
  .attr('y1', d => boundY(d.source.y))
  .attr('x2', d => boundX(d.target.x))
  .attr('y2', d => boundY(d.target.y))

并且我们还给圆点添加了拖拽:

.call(d3.drag()
    .on('start', d => {
        d.fx = d.x
        d.fy = d.y
    })
    .on('drag', d => {
        d.fx = d3.event.x
        d.fy = d3.event.y
    })
    .on('end', d => {
        d.fx = null
        d.fy = null
    }))

在效果中可以看见,当我们拖动圆点时, 与之相连的圆点也会随着一起被拖动,这就是 link 力在作用。

力气泡图

在上面的例子中,我们只需要把渲染dom的部分变为根据节点渲染封闭的 path 曲线,其实就是力气泡图了。

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style type="text/css">
        html, body {
            height: 100%;
        }
        body {
            margin: 0;
        }
        svg {
            width: 100%;
            height: 100%;
        }
        .bubble {
            stroke: grey;
            stroke-width: 1;
        }
    </style>
</head>
<body>
<svg>
    <defs>
        <radialGradient id="gradient" cx="50%" cy="50%" r="100%" fx="50%" fy="50%">
            <stop offset="0%" style="stop-color:blue;stop-opacity:0"/>
            <stop offset="100%" style="stop-color:rgb(255,255,255);stop-opacity:1"/>
        </radialGradient>
    </defs>
</svg>
<script src="../d3.js"></script>
<script>
  const width = 1280,
    height = 800,
    r = 4.5,
    nodes = [],
    links = [],
    force = d3.forceSimulation()
      .velocityDecay(0.5)
      .alphaDecay(0)
      .force('collision', d3.forceCollide(r + 0.5).strength(1))
      .force('charge', d3.forceManyBody().strength(-50).distanceMax(height / 10))

  const svg = d3.select('svg')
    .attr('width', width)
    .attr('height', height)

  const line = d3.line()
    .x(d => d.x)
    .y(d => d.y)
    .curve(d3.curveBasisClosed)

  force.on('tick', function () {
    svg.selectAll('path.bubble')
      .attr('d', d => line(d))
  })

  function boundX(x) {
    return x > r ? (x < width - r ? x : width - r) : r
  }

  function boundY(y) {
    return y > r ? (y < height - r ? y : height - r) : r
  }

  function offset() {
    return Math.random() * 10
  }

  function createNodes(point) {
    const numberOfNodes = Math.round(Math.random() * 10),
      newNodes = []

    for (let i  = 0; i < numberOfNodes; i++) {
      newNodes.push({
        x: point[0] + offset(),
        y: point[1] + offset()
      })
    }

    newNodes.forEach(p => nodes.push(p))

    return newNodes
  }

  function createLinks(nodes) {
    const newLinks = []

    for (let i = 0; i < nodes.length; i++) {
      if (i === nodes.length - 1) {
        newLinks.push({
          source: nodes[i],
          target: nodes[0]
        })
      } else {
        newLinks.push({
          source: nodes[i],
          target: nodes[i + 1]
        })
      }
    }

    newLinks.forEach(l => links.push(l))

    return newLinks
  }

  svg.on('click', function () {
    const point = d3.mouse(this),
      newNodes = createNodes(point),
      newLinks = createLinks(newNodes)

      svg.append('path')
        .data([newNodes])
        .classed('bubble', true)
        .attr('fill', 'url(#gradient)')
        .attr('d', d => {
          console.log(d)
          return line(d)
        })
        .transition().delay(10000)
        .attr('fill-opacity', 0)
        .attr('stroke-opacity', 0)
        .on('end', function () {
          d3.select(this).remove()
        })


    force.nodes(nodes)
    force.force('link', d3.forceLink(links).strength(1).distance(20))

    force.restart()
  })
</script>
</body>
</html>

效果如下:

改变的代码如下:

const line = d3.line()
    .x(d => d.x)
    .y(d => d.y)
    .curve(d3.curveBasisClosed)

force.on('tick', function () {
svg.selectAll('path.bubble')
  .attr('d', d => line(d))
})

svg.append('path')
    .data([newNodes])
    .classed('bubble', true)
    .attr('fill', 'url(#gradient)')
    .attr('d', d => {
      console.log(d)
      return line(d)
    })
    .transition().delay(10000)
    .attr('fill-opacity', 0)
    .attr('stroke-opacity', 0)
    .on('end', function () {
      d3.select(this).remove()
    })

力导向图

数据使用之前的treeData,见 juejin.cn/post/684490…

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
<script src="../d3.js"></script>
<script>
    function render(data) {
        const width = 1280,
              height = 800,
              r = 4.5,
              colors = d3.scaleOrdinal(d3.schemeCategory10),
              force = d3.forceSimulation()
                .velocityDecay(0.8)
                .alphaDecay(0)
                .force('charge', d3.forceManyBody().strength(-50))
                .force('x', d3.forceX())
                .force('y', d3.forceY()),
              svg = d3.select('body').append('svg')
                .attr('width', width)
                .attr('height', height)
                .attr("viewBox", [-width / 2, -height / 2, width, height]),
              root = d3.hierarchy(data),
              nodes = root.descendants(),
              links =  root.links()

        force.nodes(nodes)
        force.force('link', d3.forceLink(links).strength(1).distance(20))

        force.on('tick', function () {
            svg.selectAll('line')
                .attr('x1', d => d.source.x)
                .attr('y1', d => d.source.y)
                .attr('x2', d => d.target.x)
                .attr('y2', d => d.target.y)

            svg.selectAll('circle')
                .attr('cx', d => d.x)
                .attr('cy', d => d.y)
        })

        svg.selectAll('line')
            .data(links)
            .enter()
            .append('line')
            .style('stroke', '#999')
            .style('stroke-width', '1px')

        svg.selectAll('circle')
            .data(nodes)
            .enter()
            .append('circle')
                .attr('r', r)
            .attr('fill', d => colors(d.parent && d.parent.data.name))
                .call(d3.drag()
                    .on('start', d => {
                        d.fx = d.x
                        d.fy = d.y
                    })
                    .on('drag', d => {
                        d.fx = d3.event.x
                        d.fy = d3.event.y
                    })
                    .on('end', d => {
                        d.fx = null
                        d.fy = null
                    })
                )
    }

    d3.json('./treeData.json').then(data => {
        render(data)
    })
</script>
</body>
</html>

效果如下:

我们使用 hierarchy.descendants() hierarchy.links() 来获取我们要渲染的节点数据和链接数据,然后就是正常的按照数据进行渲染。对于节点,我们将同父节点下的节点设置为同颜色。