Day14-D3 的 Force 原力

本篇大纲:Force 原理、引力与斥力、五种作用力、Force的 API 们、六种应用范例

今天要来讲讲 Force 原力~这是我认为D3中数一数二困难的图表绘制方法,这类的方法主要是用来绘制下方这种力导向图
https://ithelp.ithome.com.tw/upload/images/20210926/20134930B46Ifmkugu.png

力导向图可以应用在物理模拟、资料关系图、拓扑图的绘制,虽然这些不算是很常见的图表,但用途也是蛮广的~

Force 原理

知道Force方法的用途後,现在我们来讲讲它的原理吧!探讨Froce的原理之前,我们先来说说什麽是 velocity Verlet (韦尔莱积分法)。韦尔莱积分法是牛顿力学中的一种积分方法,用来求解牛顿运动方程的数值方法,在力学模拟的运用中非常普遍。上面这段话听起来跟普通人的距离似乎很远,让我换个方式讲解~大家有没有玩过玛利欧兄弟、枫之谷、愤怒鸟、英雄联盟等等任何游戏呢?在这些游戏中,你是不是能操作自己的角色前进後退、跳上跳下、弹射打小猪呢?这些角色移动的过程、角色的位置、移动方向、是否会其他物体重叠碰撞等等的计算,靠的就是以牛顿力学理论为基础去运算,而我们的 D3 Force 一样也是运用了韦尔莱积分法模拟粒子物理运动的原理来绘制力导向图。

使用 D3 的 Force时,一开始会先把所有的元素设定任意的初始值配置,接着套用一些设定,再让元素依照这些设定去移动,这些移动就是模拟粒子物理运动的原理。

引力与斥力

当我们使用 d3.forceSimulation( ) 方法建立力模拟器後,力模拟器会模拟粒子运作的方式,把画面上的DOM元素们视为一个个的节点,这些节点彼此有斥力,而整体则是有引力牵引。随着程序的运行,节点会在各种力的交互作用、碰撞聚拢下,逐渐收敛到一个稳定位置。这些节点收敛的方式是使用一个叫 alpha 的参数去控制,透过d3提供的API可以控制alpha的递减速度来改变力模拟图收敛的速度;除此之外,还能设定节点的摩擦系数去调整收敛的速度,至於有哪些 API 我们晚点会一一讲解。

设定完力模拟器并绑定到节点上後,模拟器会对每个节点建立以下成员

  • index ⇒ 第几个的元素
  • name ⇒ 绑定的资料
  • vx, vy ⇒ 速度
  • x, y ⇒ 位置
  • (fx, fy) ⇒ 固定的位置,预设是没有的

https://ithelp.ithome.com.tw/upload/images/20210926/20134930CuJYP2VVmY.jpg

之後我们就能将这些位置的资料重新赋予给node节点,让节点调整到对应的位置

五种作用力

除了引力跟斥力之外,力导向图还可以透过另外这五种作用力对每一个节点做相关的设定

  • 中心力 ⇒ d3.forceCenter

    中心力作用於所有的节点而不是单独的节点。它可以把所有节点的中心一致朝指定的位置平移,而且这种移动不会修改速度也不会影响节点间的相对位置。

    forceCenter 旗下有一些API 可以进行相关设定:
    https://ithelp.ithome.com.tw/upload/images/20210926/20134930GSOD5rjbfa.jpg

  • 碰撞力 ⇒ d3.forceCollide

    节点之间互相排斥的斥力(相互碰撞排斥),这个斥力会阻止节点相互重叠,可以使用 .strength( ) 这个API去设定斥力的强度

    forceCollide 旗下有一些API 可以进行相关设定:
    https://ithelp.ithome.com.tw/upload/images/20210926/20134930iTsRh8kgPD.jpg

  • 连结力 ⇒ d3.forceLink

    使用 d3.forceLink( ) 这个 API 将两个节点设定 link 连线到一起之後,就可以开始设定连结力了~这个力会根据两个节点间的距离把两个节点拉近或推远,力的强度和两者的距离成正比,就像弹簧一样。

    forceLink 旗下有一些 API 可以进行相关设定:
    https://ithelp.ithome.com.tw/upload/images/20210926/20134930w3Gv5zNxuU.jpg

  • 电荷力 ⇒ d3.forceManyBody

    模拟所有节点间的相互作用力,如果设定是正值,节点间就会相互吸引;如果设定是负值,节点间就会相互排斥。这样可以用来模拟电荷吸引力,而力的大小也和节点间的距离有关。

    forceManyBody 旗下有一些API 可以进行相关设定:
    https://ithelp.ithome.com.tw/upload/images/20210926/201349303JfGnedqZn.jpg

  • 定位力 ⇒ d3.forceXd3.forceYd3.forceRadial

    定位力可以把节点沿着指定的维度推向一个指定位置,这些位置又可以透过三个不同的API去设定

    • d3.forceX:在 X轴方向推或者拉所有的节点

    • d3.forceY:在 Y轴 方向推或者拉所有的节点

    • d3.forceRadial:形成一个圆环,把所有的节点都往这个圆环上相应的位置推

      这三个方法旗下也有一些 API 可以进行相关设定:
      https://ithelp.ithome.com.tw/upload/images/20210926/2013493020TVhLBkDG.jpg

由於 force 的细节设定实在太多,这边只能挑常用的几个讲,如果想实际操作看各种参数的设定,可以玩玩看这个范例。这个范例非常厉害,作者把各种参数都放在左方的选择栏,我们可以藉由调整数值去看这些参数对力导向图的作用
https://ithelp.ithome.com.tw/upload/images/20210926/20134930Tcy3BBigDU.jpg


看完上面的解说後,我们赶快来实际看看要怎麽建立力学图吧!

d3.forceSimulation( )

使用 D3 来建立力学导向图时,我们要先用 d3.forceSimulation 建立一个力模拟器并指定到 DOM 元素身上,这个模拟器就能用来监听整体内部node变化,并开始执行、暂停或重新启动引力模拟。如果没有指定 DOM 元素就会默认为空阵列,力模拟器会自动启动。

一旦建立好力模拟器之後,它就会开始自动执行。接着会在每一个动画影格进行一次模拟粒子运动的迭代,而整体的模拟预设是在300个迭代後结束。我们可以使用属於力模拟器 simulation 旗下的 API 来设定这些力学的参数~一样先看到官方文件提供哪些方法:
https://ithelp.ithome.com.tw/upload/images/20210926/20134930CYlEFPAqMZ.jpg

接着看到一些比较常用的:

  • simulation.restartsimulation.stopsimulation.tick

    这几个方法是来控制模拟器重新执行、停止、停止後手动前进

  • simulation.nodes

    将力模拟图建立的元素与资料绑定

  • simulation.alphasimulation.alphaMinsimulation.alphaDecay

    前面有提过,力模拟图的节点们向中心聚拢的收敛方式是使用 alpha 的参数去控制,这几个 API 就是用来控制 alpha 的值。在力模拟图一建立并开始执行时,alpha 值设定为1,并在经过 300次迭代 (iterations) 之後衰减为0,这时候就会看到整个力模拟图呈现静止的状态。如果希望力模拟图永远不静止的话,可以把 alpha 的参数设定为0

    — simulation.alphaMin:alpha 小於 alphaMin 设定的值时,力模拟器就会停止运作

    — simulation.alphaDecay: 控制 alpha 减少的速度

  • simulation.velocityDecay

    设定摩擦力的系数,数值1是无摩擦力的状态(粒子运动很快),数值0则会让所有粒子失去速度(冻结静止),

  • simulation.force(name[,force])

    建立好一个力模拟器後,我们可以使用 simulation.force() 把前面提到的五种不同作用力当成参数添加到力模拟器中。第一个参数name可以任意自订想取的名称,第二个参数则填入要使用的作用力。举几个常见的例子来说:

    const simulation = d3.forceSimulation(nodes) 
                  // 设定节点连结的引力 
                  .force('link', d3.forceLink())
                  // 在 y轴方向上施加一个力
                  .force('y', d3.forceY().strength(0.025))
                  // 节点间的电荷力
                  .force('charge', d3.forceManyBody())
                  // 节点间的斥力
                  .force('collision', d3.forceCollide().radius(d => 4))
                  //中心点的引力
                  .force('center', d3.forceCenter(width / 2, height / 2))
    
  • simulation.on(事件, 方法)

    启动模拟器的事件监听,并设定之後的callback function。力模拟器的事件监听有两种:

    tick ⇒ 内部定时器每次运行 tick 之後

    end ⇒ 当alpha < alphaMin,因此定时器停止之後


范例一:力图

接着我们来看看范例吧!首先,先从最基本的力图开始示范~以下是我们现在有的资料

// html
<svg class="forceElement" style="width:500; height:300"></svg>

// js
const data = [{name:'A'},{name:'B'},{name:'C'},{name:'D'},{name:'E'},{name:'F'},{name:'G'}]

我们先把资料都绑定到DOM上,并把这些资料都放在同样的位置

const dots =  d3.select('.forceElement')
                    .append('g')
                    .selectAll('circle')
                    .data(data)
                    .enter()
                    .append('circle')
                    .attr('cx', 250)
                    .attr('cy', 150)
                    .attr('r', 15)
                    .style('fill', 'green')
                    .style('opacity', 0.4)

就会获得一个全部点点都在中间的图
https://ithelp.ithome.com.tw/upload/images/20210926/20134930ZuQIIiSOVe.jpg

接着,开始建立力模拟器吧!

// 设定力模拟
const simulation = d3.forceSimulation()
 .alphaDecay(0) // 收敛永不停止
 .velocityDecay(0.2) // 设定摩擦系数
 .force("x", d3.forceX()) // 设定X轴平移位置
 .force("y", d3.forceY()) // 设定Y轴移动位置
 // 设定中心点位置
 .force("center", d3.forceCenter().x(250).y(150)) 
 // 设定节点间电荷力
 .force("charge", d3.forceManyBody().strength(1))
 // 设定节点间彼此的互斥力
 .force("collide", d3.forceCollide().strength(0.1).radius(40).iterations(0.2))

设定完力模拟器後,我们将模拟器绑定节点跟资料,并把每个DOM元素的位置设定为力模拟器返还的x, y 轴位置

// 将力模拟器的节点绑定资料,设定ticks开始时节点的动作
simulation.nodes(data)
          .on("tick", function(d){
             dots.attr("cx", d => d.x)
                 .attr("cy", d => d.y)
            });

完成!这样我们就得到了一个最基础的力导向图了~
https://i.imgur.com/C6tiO7P.gif

范例二:力图-资料分组

接着我们来做个进阶一点的力导向图~首先,一样先把手上的资料绑定到DOM上。设定颜色的 scaleOrdinal 方法大家可以先看过就好,後续的scale( )章节会再来仔细讲解

// html
<svg class="forceGroup" style="width:500; height:300"></svg>

//js 
const data = [
        { "name": "A", "group": 150 },
        { "name": "B", "group": 150 },
        { "name": "C", "group": 150 },
        { "name": "D", "group": 150 },
        { "name": "E", "group": 150 },
        { "name": "F", "group": 150 },
        { "name": "G", "group": 250 },
        { "name": "H", "group": 250 },
        { "name": "I", "group": 250 },
        { "name": "J", "group": 250 },
        { "name": "K", "group": 250 },
        { "name": "L", "group": 350 },
        { "name": "M", "group": 350 },
        { "name": "N", "group": 350 },
        { "name": "O", "group": 350 }
    ]

    // 设定颜色
    const colorScale = d3.scaleOrdinal()
	                        .domain([150, 250, 350])
	                        .range(["red", "blue", "orange"])

    // 建立圆点,全都位於正中央
    const node = d3.select('.forceGroup')
               .append('g')
               .selectAll("circle")
               .data(data)
               .enter()
               .append('circle')
               .attr('r', 20)
               .attr('cx', 250)
               .attr('cy', 150)
               .style('fill', d=>colorScale(d.group))
               .style('opacity', '0.6')

接着一样来设定力模拟器,但这次要在 d3.forceX( ) 这边增加一个设定。使用 d3.forceX().strength(0.5).x(d => d.group) 来设定根据 group 这个key去将我们的资料分组,同一组的放在一起

// 设定力模拟器
const simulation = d3.forceSimulation()
    .force("x", d3.forceX().strength(0.5).x(d => d.group))
    .force("y", d3.forceY().strength(0.1).y( 150 ))
    .force("center", d3.forceCenter().x(250).y(150)) 
    .force("charge", d3.forceManyBody().strength(1)) 
    .force("collide", d3.forceCollide().strength(.1).radius(20).iterations(1))

最後,把这个力模拟器绑定资料

// 将力模拟器的节点绑定资料
simulation.nodes(data)
          .on("tick", function(d){
            node.attr("cx", d => d.x)
                .attr("cy", d => d.y)
            });

这样一来,我们就能成功把资料分类并放在不同组啦!
https://i.imgur.com/EKCFvhN.gif

范例三:力图-资料连结

再来,我们来写写看这种搭配连结的力导向图吧!
https://ithelp.ithome.com.tw/upload/images/20210926/201349302ULPwQJv6H.jpg

这边要注意的是,我们的资料除了要有设定圆点的data之外,也要有设定link的data才能绘制出连结的线。link的资料包含两种资讯:

  • source ⇒ 目前的点
  • target ⇒ 要串接的点

它们用来设定哪些点要用link去串接在一起,所以我们的资料就会长这样

// html
<svg class="forceLink" style="width:500; height:300"></svg>

// js
const data = {
    "nodes": [
        {"id": 1, "name": "A"},
        {"id": 2, "name": "B"},
        {"id": 3, "name": "C"},
        {"id": 4, "name": "D"},
        {"id": 5, "name": "E"},
        {"id": 6, "name": "F"},
        {"id": 7, "name": "G"},
        {"id": 8, "name": "H"},
        {"id": 9, "name": "I"},
        {"id": 10, "name": "J"}
			    ],
    "links": [
        {"source": 1, "target": 2},
        {"source": 1, "target": 3},
        {"source": 1, "target": 6},
        {"source": 2, "target": 3},
        {"source": 2,"target": 7},
        {"source": 3,"target": 4},
        {"source": 8,"target": 3},
        {"source": 4,"target": 5},
        {"source": 4,"target": 9},
        {"source": 5,"target": 10}
      ]
    }

要注意的是,大部分人一定不会拿到这麽漂亮的资料,一般公司的资料也不是长这样 ( 哪来的source 跟 target )。所以当你拿到资料时,请先用前几章提到的资料整理API们去把资料整理成自己想要的格式。

整理好资料後,我们把资料绑定到DOM上,来建立原点跟连结线段

const dots =  d3.select('.forceLink')
                    .append('g')
                    .selectAll('circle')
                    .data(data.nodes)
                    .enter()
                    .append('circle')
                    .attr('r', 15)
                    .style('fill', 'green')
                    .style('opacity', 0.4)

  const link = d3.select('.forceLink')
                  .selectAll("line")
                  .data(data.links)
                  .join("line")
                  .style("stroke", "#aaa")

接着建立力模拟器,并把力模拟器绑定原点跟线段,这样就能得到有线段连结的力导向图啦!

  // 设定力模拟器
  const simulation = d3.forceSimulation(data.nodes)
          .force("link",  d3.forceLink().id(d=> d.id).links(data.links))
          .force("charge", d3.forceManyBody().strength(-300))
          .force("center", d3.forceCenter(250, 150))
          .on('tick', ticked)
                      

  // 设定 ticked 方法
  function ticked(d){
      link
      .attr("x1", function(d) { return d.source.x; })
      .attr("y1", function(d) { return d.source.y; })
      .attr("x2", function(d) { return d.target.x; })
      .attr("y2", function(d) { return d.target.y; });

      dots.attr("cx", d=> d.x)
          .attr("cy", d => d.y);
   }

范例四:力图-结合hover

这个范例我们来结合 d3 的hover事件跟 tooltips 玩玩看~我想要滑鼠滑过力图时,会显示当下圆点的半径长度,实际操作如下图
https://i.imgur.com/uwd5PHr.gif

一样先把资料绑定到DOM元素上,并且设定跟绑定力模拟器

// html
<div class="hoverWrap position-relative">
    <svg class="forceHover" style="width:500; height:300"></svg>
</div>

// js
const data = [{'r': 3}, {'r':34}, {'r': 23}, {'r': 33},
    {'r': 13},{'r': 22},{'r': 43},{'r': 17},{'r': 38}]

const dots = d3.select('.forceHover')
                .selectAll('circle')
                .data(data)
                .enter()
                .append('circle')
                .attr('r', d => d.r)
                .attr('fill', 'blue')
                .attr('opacity', 0.3)
// 设定力模拟器
const simulation = d3.forceSimulation()
        .force("center", d3.forceCenter().x(250).y(150)) 
        .force("charge", d3.forceManyBody().strength(.3)) 
                    .force("collide",d3.forceCollide().strength(.1).radius(30).iterations(1))
//绑定资料
simulation.nodes(data)
          .on('tick', function(d){
            dots.attr('cx', d=>d.x)
                .attr('cy', d=>d.y)
          })

接着我们建立tooltips,并启动原点的 mouseover 跟 mouseleave事件

// 建立tooltips
const tooltips = d3.select(".hoverWrap")
                .append("div")
                .style("opacity", 0)
                .style('position', 'absolute')
                .attr("class", "tooltip")
                .style("background-color", "white")
                .style("border", "solid")
                .style("border-width", "2px")
                .style("border-radius", "5px")
                .style("padding", "5px")
// hover
dots.on('mouseover', mouseover)
    .on('mouseleave', mouseleave)

再来我们要设定mouseover 跟 mouseleave时要执行的方法,并且使用 d3.pointer 去指定tooltips的位置

function mouseover(event, d){
    console.log(d)
    d3.select(this)
      .attr('stroke', 'black')
      .attr('stroke-width', '3px')
      .attr('opacity', 0.7)
      .style('cursor', 'pointer')
    
    let pt = d3.pointer(event, this)
    tooltips.style('opacity', 1)
            .style('left', pt[0]+ 10 +'px')
            .style('top', pt[1]+'px')
            .html(`半径:${d.r}`)
}

function mouseleave(event, d){
    d3.select(this)
      .attr('stroke', 'none')
      .attr('stroke-width', '0')
      .attr('opacity', 0.3)
    
    tooltips.style('opacity', 0)
}

这样就完成啦~~

范例五:力图-结合drag

再来一个范例~~~这次我们要结合拖曳事件,但我们先来个基础版,只要拖曳圆点就好
https://i.imgur.com/ae1Krg6.gif

我们先把资料绑定到DOM元素上,并建立力模拟器

// 资料
const data = [{name:'A'},{name:'B'},{name:'C'},{name:'D'},{name:'E'},{name:'F'},{name:'G'}]

// 建立原点,目前全部都在同个位置
const dots = d3.select('.forceDrag')
        .append('g')
        .selectAll('circle')
        .data(data)
        .enter()
        .append('circle')
        .attr('r', 25)
        .attr('cx', 50)
        .attr('cy', 50)
        .style("fill", "#19d3a2")
        .style("fill-opacity", 0.3)
        .attr("stroke", "#b3a2c8")
        .style("stroke-width", 4)
        .style('cursor', 'pointer')

// 建立力模拟图
const simulation = d3.forceSimulation()
    .force("center", d3.forceCenter().x(200).y(150))
    .force("charge", d3.forceManyBody().strength(1))
    .force("collide", d3.forceCollide().strength(.1).radius(30).iterations(1));

simulation.nodes(data)
          .on("tick", function(d){
               dots.attr("cx", d => d.x)
                   .attr("cy", d => d.y)
            });

再来要把拖曳事件绑定到DOM元素上,并且设定拖曳的方法

// 拖曳开始
function dragstarted(event, d) {
    // console.log(d)
    d3.select(this)
      .style('fill-opacity', 0.6)
    d.fx = d.x;
    d.fy = d.y;
    simulation.alphaTarget(.03).restart();
}
// 拖曳期间
function dragged(event, d) {
    d.fx = event.x;
    d.fy = event.y;
}
// 拖曳结束
function dragended(event, d) {
    simulation.alphaTarget(.03);
    d3.select(this)
      .style('fill-opacity', 0.3)
    d.fx = null;
    d.fy = null;
}

// 绑定拖曳事件
dots.call(d3.drag() 
            .on("start", dragstarted)
            .on("drag", dragged)
            .on("end", dragended));

范例六:力图-结合drag+Link

最後一个范例,我们来看看把有连结的力图加上拖曳效果该怎麽做吧~其实这个也很简单,就是结合范例三跟范例五而已。

首先,我们先建立好带有连结线条的力图

// html
<svg class="forceDragLink" style="width:500; height:300"></svg>

// js
const data = {
    "nodes": [
        {"id": 1, "name": "A"},
        {"id": 2, "name": "B"},
        {"id": 3, "name": "C"},
        {"id": 4, "name": "D"},
        {"id": 5, "name": "E"},
        {"id": 6, "name": "F"},
        {"id": 7, "name": "G"},
        {"id": 8, "name": "H"},
        {"id": 9, "name": "I"},
        {"id": 10, "name": "J"}
			    ],
    "links": [
        {"source": 1, "target": 2},
        {"source": 1, "target": 3},
        {"source": 1, "target": 6},
        {"source": 2, "target": 3},
        {"source": 2,"target": 7},
        {"source": 3,"target": 4},
        {"source": 8,"target": 3},
        {"source": 4,"target": 5},
        {"source": 4,"target": 9},
        {"source": 5,"target": 10}
      ]
    }

const dots =  d3.select('.forceDragLink')
                    .append('g')
                    .selectAll('circle')
                    .data(data.nodes)
                    .enter()
                    .append('circle')
                    .attr('r', 15)
                    .style('fill', 'green')
                    .style('opacity', 0.6)
                    .style('cursor', 'pointer')

  const link = d3.select('.forceDragLink')
                  .selectAll("line")
                  .data(data.links)
                  .join("line")
                  .style("stroke", "#aaa")
  
  // 设定力模拟器
  const simulation = d3.forceSimulation(data.nodes)
      .force("link",  d3.forceLink().id(d=> d.id).links(data.links))
      .force("charge", d3.forceManyBody().strength(-300))
      .force("center", d3.forceCenter(250, 150))
      .on('tick', ticked)
                      

  // 绑定节点
  function ticked(d){
      link
      .attr("x1", function(d) { return d.source.x; })
      .attr("y1", function(d) { return d.source.y; })
      .attr("x2", function(d) { return d.target.x; })
      .attr("y2", function(d) { return d.target.y; });

      dots.attr("cx", d=> d.x)
          .attr("cy", d => d.y);
   }

接着再绑定拖曳事件

// 绑定拖曳事件
dots.call(d3.drag() 
            .on("start", dragstarted)
            .on("drag", dragged)
            .on("end", dragended));

function dragstarted(event, d){
    d3.select(this)
      .style('fill', 'pink')
    d.fx = d.x;
    d.fy = d.y;
    simulation.alphaTarget(.03).restart(); // 停止後需要重新开始
}

function dragged(event, d){
    d.fx = event.x;
    d.fy = event.y;
}

function dragended(event, d){
    d3.select(this)
      .style('fill', 'green')
      .style('opacity', 0.6)
    d.fx = null
    d.fy = null
}

这样就完成啦~
https://i.imgur.com/BlIWeRi.gif

以上就是D3 Force 的详细解说~终於讲完了(撒花)!下篇就是比较简单的缩放效果~


Github Page 图表与 Github 程序码

这边附上本章的程序码与图表 GithubGithub Page,需要的人请自行取用~


<<:  Day12 Sideproject(作品集) from 0 to 1 -docker後端

>>:  [番外] 来个 Weather App (续)

Day09 - 网站开发从Django开始

昨天我们完成了虚拟环境安装,而为了让後续的挑战更具连贯性,对於後面几个主题的顺序有稍作挑整,故从今天...

Day17-Node.js

前言 今天是第十七天,很高兴我的意志力让我挺过了一半的铁人赛? 前面十六篇的JS章节,其实已经把Ja...

用 Queue 制作 Stack

记录学习内容。 以下内容和截图大多引用文章。 还不了解,内容可能有错误。 Implement Sta...

Day 9 - Container With Most Water

大家好,我是毛毛。ヾ(´∀ ˋ)ノ 废话不多说开始今天的解题Day~ 11. Container W...

Day18 Laravel - CRUD .feat RESTful API

过了17天的铺陈终於迎来了我最喜欢的18天,前面已经建立起一个良好的基础环境可以好好的开始专案了,所...