Day25-D3 基础图表:折线图+ d3.bisector( )与 d3.defined( )

本篇大纲:d3.line( )、d3.bisector( )、d3.defined( )、范例图表一、范例图表二

今天的一天一图表,我们要来看到折线图!折线图跟长条图一样都是最常见的图表,相信很多人一开始学 D3 也都是先画这两种图表。但跟长条图稍有不同的是:折线图的线条是由单独一条 < path > 组成,不像长条图是由好几条< rect >组成。
https://ithelp.ithome.com.tw/upload/images/20211007/201349309c7mnZNuJR.jpg

d3.line( )

要绘制折线图最重要的就是这条< path >。在 svg 的篇章中,我们有讲到 path 只有一个属性d,透过这个 d 的属性值去绘制出线条。而在 Helper Function 的篇章中则讲解 如何使用 d3.line( ) 去生成 d 的命令列字串。我们使用 d3.line( ) 去处理资料并生成 d 的命令列字串之後,就能将这个 d 带回给 < path >并绘制折线啦!

范例折线图表一

知道要怎麽画折线图之後,我们先来看看这次要做的图表 (知道自己要面对什麽,死得比较安心)。这次我们有两个范例图表要绘制,先看到第一个图表:
https://i.imgur.com/EzgEDtu.gif

图表一的资料

这次的资料一样使用真实世界的资料~这两年来大家最关心的应该就是疫情状况,我们就用疾管署提供的 COVID19 病例数资料来绘制这次的长条图吧!进到疾管署提供的资料页面後,点击右上角的下载小箭头把 csv 档载下来
https://ithelp.ithome.com.tw/upload/images/20211007/201349306ebcAtGRAu.jpg

打开来後我们的资料长这样
https://ithelp.ithome.com.tw/upload/images/20211007/20134930nBC1iBy8fX.jpg

图表一的画面与互动

接着来看看这次要做的互动效果是什麽~是的,我几乎每个图表都有加互动效果,毕竟单纯渲染图表太无聊而且范例到处都是,做点互动会更有趣!这次范例包含:

  • 基础折线图
  • 滑鼠 hover 时,呈现目前的资讯

绘制图表一

知道要做什麽之後,赶快进入图表吧!首先,一样先取资料、建立 svg

// css
.chart{
    width: 100%;
    min-width: 300px;
    margin: auto;
}
// html
<div class="chart"></div>

这边我们先将取得的资料用 filter( ) 处理一下,因为我只想要抓 2021年的资料

let data = []
async function getData() {
  // 取资料
  dataGet = await d3.csv('./data/20201-202140-covid19.csv')
  data = dataGet.filter(i=>i['发病年周']>'202101')
  drawChart()
};
getData()

// RWD
function drawChart(){
  // 删除原本的svg.charts,重新渲染改变宽度的svg
  d3.select('.chart svg').remove();

  // RWD 的svg 宽高
  const rwdSvgWidth = parseInt(d3.select('.chart').style('width')),
        rwdSvgHeight = rwdSvgWidth*0.8,
        margin = 40,
        bandWidth = 20 

  const svg = d3.select('.chart')
                .append('svg')
                .attr('width', rwdSvgWidth)
                .attr('height', rwdSvgHeight);

// 接下来的程序码放这边...
// 接下来的程序码放这边...
// 接下来的程序码放这边...

}

d3.select(window).on('resize', drawChart);

资料取得并过滤之後,接着我们要来建立XY轴跟比例尺啦。先把XY轴各自要用到的资料整理出来,并且设定比例尺并建立轴线

// map 资料集
xData = data.map((i) => parseInt(i['发病年周'].substring(4,6))); // 取周数
yData = data.map((i) => parseInt(i['确定病例数']));

// 设定要给 X 轴用的 scale 跟 axis
const xScale = d3.scaleLinear()
                .domain(d3.extent(xData))
                .range([margin, rwdSvgWidth - margin]); // 宽度

// rwd X轴的刻度
let tickNumber = window.innerWidth > 900 ? xData.length/2 : 8;
const xAxis = d3.axisBottom(xScale)
                .ticks(tickNumber)
                .tickFormat(d=>d+'周')

// 呼叫绘制x轴、调整x轴位置
const xAxisGroup = svg.append("g")
                      .call(xAxis)
                      .attr("transform", `translate(0,${rwdSvgHeight - margin})`)

// 设定要给 Y 轴用的 scale 跟 axis
const yScale = d3.scaleLinear()
                .domain([0, d3.max(yData)])
                .range([rwdSvgHeight - margin, margin]); // 数值要颠倒,才会从低往高排

const yAxis = d3.axisLeft(yScale)
                .ticks(5)

// 呼叫绘制y轴、调整y轴位置
const yAxisGroup = svg.append("g")
                      .call(yAxis)
                      .attr("transform", `translate(${margin},0)`)

然後就是建立折线图!建立轴线图之前,我们要先用 d3.line( ) 的方法把 path 需要的 d 属性值建立出来

// 设定 path 的 d 
const lineChart = d3.line()
          .x((d) => xScale(parseInt(d['发病年周'].substring(4,6))))
          .y((d) => yScale(parseInt(d['确定病例数'])))

再把 lineChart 这个方法带入资料,并把返还的值赋予给 path

// 建立折线图
svg.append('path')
    .data(data)
    .attr("d", lineChart(data))
    .attr("fill", "none")
    .attr("stroke", "steelblue")
    .attr("stroke-width", 1.5)

折线图就简简单单的完成了~~
https://ithelp.ithome.com.tw/upload/images/20211007/20134930yV6pS2ZNrp.jpg

pointer-event

接着就是做更困难的滑鼠互动啦!之前长条图的滑鼠互动都是直接绑定在 < rect > 上,但折线图不能把滑鼠互动绑定在 < path >上,因为< path >实在太细了,使用者根本无法精确的触发滑鼠互动。因此,我们要先建立覆盖整个画面的方形,然後使用 pointer-event 的 css 来触发滑鼠事件。

pointer-event 主要是提供给 svg 使用的属性,用来处理滑鼠事件。预设值为 auto,若值为 none,则可以穿越该元素,点击到下方的元素。这边我们要使用的是另一个属性值 all,它能让滑鼠在元素内部或边界时才会触发

// 建立一个覆盖svg的方形
svg
    .append('rect')
    .style("fill", "none")
    .style("pointer-events", "all")
    .attr('width', rwdSvgWidth - margin)
    .attr('height', rwdSvgHeight - margin)
    .style('cursor', 'pointer')

建立好矩形後我们的画面长这样,为了让大家能看见矩形,我先把它加个半透明的背景色
https://ithelp.ithome.com.tw/upload/images/20211007/20134930LU9bNBwhMw.jpg

接着我们再对这个矩形绑定滑鼠监听事件,这样滑鼠事件就能启用,否则我们的< rect > 是透明的根本看不见

// 建立一个覆盖svg的方形
svg
    .append('rect')
    .style("fill", "none")
    .style("pointer-events", "all")
    .attr('width', rwdSvgWidth - margin)
    .attr('height', rwdSvgHeight - margin)
    .style('cursor', 'pointer')
    .on('mouseover', mouseover)
    .on('mousemove', mousemove)
    .on('mouseout', mouseout);

现在加上滑鼠滑过时,折线上要出现的圆点跟资讯标签

// 建立沿着折线移动的圆点点
const focus = svg.append('g')
                 .append('circle')
                 .style("fill", "none")
                 .attr("stroke", "black")
                 .attr('r', 8.5)
                 .style("opacity", 0)

// 建立移动的资料标签
const focusText = svg.append('g')
                    .append('text')
                    .style("opacity", 0)
                    .attr("text-anchor", "left")
                    .attr("alignment-baseline", "middle")

d3.bisector( )

接着要设定滑鼠事件触发的方法们。但这时会出现一个问题,要怎麽知道滑鼠滑到哪边要加上圆点呢? 我们就要利用另一个 d3 的方法:d3.bisector( )

在讲 d3.bisector( ) 之前,我们要先谈谈 d3.bisect( ) 这个方法。d3.bisect( )是用来**寻找某数值对应一个资料阵列中的正确位置/最接近的位置**,使用时必须带入参数。它的参数可以多达四个,分别为 d3.bisect(array, value, [start, end] )

  • data:要对应的资料阵列
  • value:要寻找位置的数值
  • start:寻找的起始范围,可以不设定
  • end:寻找的终点范围,可以不设定

假设我们现在有一个资料阵列如下

const data = [0, 1, 2, 3, 4]

我们想按照排序插入一笔资料:1.25,这时候就可以用 d3.bisect( ) 找出这笔资料在整个资料阵列中,应该要在哪个位置(index)

const data = [0,1,2,3,4]
d3.bisect(data , 1.25)  // return 2

返还 index = 2 ,代表 1.25 这笔资料应该要插入到阵列中 index = 2 的位置

d3.bisect( ) 也提供了另外三个旗下的API 来设定插入的位置

  • d3.bisectLeft( )
  • d3.bisectRight( )
  • d3.bisectCenter( )

d3.bisector( ) 跟 d3.bisect( ) 的用途一样,差别在於 d3.bisector( ) 是要带入一个方法作为参数,这样就能用来搜寻整个物件资料,而不单只限阵列资料。举例而言,我们有以下的资料

const data = [
  {date: new Date(2011, 1, 1), value: 0.5},
  {date: new Date(2011, 2, 1), value: 0.6},
  {date: new Date(2011, 3, 1), value: 0.7},
  {date: new Date(2011, 4, 1), value: 0.8}
];

这时候就能用 d3.bisector( ) 来寻找

const bisectDate = d3.bisector(d=>d.date).right;

有看到後面还加了一个 .right 的方法吗? d3.bisector( ) 旗下其实还提供了三种方法来设定要插入的资料是从左边或右边寻找

  • bisector.left( )
  • bisector.right( )
  • bisector.center( )

了解 d3.bisector( ) 要怎麽用之後,现在我们就实际来看看程序码要怎麽写吧!

// 使用 d3.bisector() 找到滑鼠的 X 轴 index 值
const bisect = d3.bisector(d=>d['发病年周']).left;

// 滑鼠事件触发的方法
function mouseover(){
      focus.style("opacity", 1)
      focusText.style("opacity",1)
    }

    function mousemove(){
      // 把目前X的位置用xScale去换算
      const x0 = xScale.invert(d3.pointer(event, this)[0]) 
      // 由於我的X轴资料是撷取过的,这边要整理并补零
      const fixedX0 = parseInt(x0).toString().padStart(2,'0')
      // 接着把撷取掉的2021补回来,因为data是带入原本的资料
      let i = bisect(data, '2021'+ fixedX0)
      selectedData = data[i]

      // 圆点
      focus
      // 换算到X轴位置时,一样使用撷取过的资料,才能准确换算到正确位置
      .attr("cx", xScale(selectedData['发病年周'].substring(4,6)))
      .attr("cy", yScale(selectedData['确定病例数']))

      focusText
      .html('确诊人数:' + selectedData['确定病例数'])
      .attr("x", xScale(selectedData['发病年周'].substring(4,6))+15)
      .attr("y", yScale(selectedData['确定病例数']))

    }

    function mouseout(){
      focus.style("opacity", 0)
      focusText.style("opacity", 0)
    }

这样就完成啦!即使没有滑在轴线上,只要滑到轴线的范围就会出现圆点跟文字资讯罗
https://i.imgur.com/EzgEDtu.gif

范例折线图表二

接着我们来看看折线图范例二,这个是我觉得很有趣的图表~一样先看范例
https://i.imgur.com/m5nB0Al.gif

Missing Data 缺少资料的情况

有时候我们会拿到一些不是那麽完整的资料,它们某些数值缺失了,通常以零、NaN、undefined 来代表,例如:

const data = [{x:1, y:120}, 
                  {x:2, y:355},
                  {x:3, y:0},  // 或 y:NaN
                  {x:4, y:470},
                  {x:5, y:19},
                  {x:6, y:90},
                  {x:7, y:0}, // 或 y:NaN
                  {x:8, y:220}
                ]

这样的资料会让我们的图表看起来起伏剧烈,但其实只是部分数值缺失而已
https://ithelp.ithome.com.tw/upload/images/20211007/2013493047rYRqUD8B.jpg

所以我们要用 d3 提供的 line.defined( ) 方法,将图表改成用虚线方式呈现
https://ithelp.ithome.com.tw/upload/images/20211007/20134930VUrBuyAlQo.jpg

line.defined( )

在开始进入程序码之前,我们要先来看看 line.defined( ) 这个API。它是d3.line( ) 旗下的方法 (代表只有d3.line( )可以使用,别用错了),会回传 true 或 false 来决定这笔资料是否存在。

预设的情况下,我们所有的资料都会回传 true,但如果资料数值是 NaN 或 undefined,就会被视为不存在。若不想呈现某些特定数值的资料,也可以用line.defined( )来排除掉

const data = [{x:1, y:120}, 
              {x:2, y:355},
              {x:3, y:0},
              {x:4, y:470},
              {x:5, y:19},
              {x:6, y:90},
              {x:7, y:0},
              {x:8, y:220}
            ]

// 用 line.defined 过滤掉y是零的数值
const lineChart = d3.line()
                    .x((d) => xScale(d.x))
                    .y((d) => yScale(d.y))
                    .defined((d) => d.y >0)

lineChart(data)  // 得到过滤掉 {x:3, y:0}, {x:7, y:0}的数值建立的 d 属性值

范例二图表绘制

了解完怎麽使用 line.defined( ) 之後,我们来实际看程序码吧。由於符合我们设定的资料比较难找,所以我这边就先用自己定义的资料代替。首先一样先建立画面

// html
<div class="definedLineChart" style="position:relative"></div>

// js

const data = [{x:1, y:120}, 
              {x:2, y:355},
              {x:3, y:0},
              {x:4, y:470},
              {x:5, y:19},
              {x:6, y:90},
              {x:7, y:0},
              {x:8, y:220}
            ]
    const width = 500,
          height = 400,
          margin = 40;
    
    const svg = d3.select('.definedLineChart')
                  .append('svg')
                  .attr('width', width)
                  .attr('height', height)
    
    // 整理XY资料
    const xData = data.map(d=>d.x);
    const yData = data.map(d=>d.y);

    // X 比例尺与轴线
    const xScale = d3.scaleLinear()
                     .domain(d3.extent(xData))
                     .range([margin, width - margin])
    const xAxis = d3.axisBottom(xScale)
                    .ticks(8)
                    .tickFormat(d=>d + '月')

    svg.append('g')
       .call(xAxis)
       .attr('transform', `translate(0, ${height-margin})`)
    
    // Y 比例尺与轴线
    const yScale = d3.scaleLinear()
                  .domain(d3.extent(yData))
                  .range([height - margin, margin])
                  .nice()

    const yAxis = d3.axisLeft(yScale)

    svg.append('g')
       .call(yAxis)
       .attr('transform', `translate(${margin}, 0)`)

再来设定滤掉y值为零的资料

// 建立折线图 path 的 d 数值
// 用 line.defined 过滤掉是零的数值
const lineChart = d3.line()
                    .x((d) => xScale(d.x))
                    .y((d) => yScale(d.y))
                    .defined((d) => d.y >0)

把设定好的 d 属性值一并加到DOM上,绘制出折线图

// 建立折线
svg.append('g')
   .append('path')
   .data(data)
   .attr("fill", "none")
   .attr("stroke", 'green')
   .attr("stroke-width", 1.5)
   .attr('d', lineChart(data))

这时我们的图表就会长成这样
https://ithelp.ithome.com.tw/upload/images/20211007/201349300L4t0c5VIi.jpg

但是我们希望中段的两个点之间是用需线连接呀~~该怎麽做呢?其实也非常简单,就是再画一条过滤掉资料的折线,并把折线设定成虚线线段就好。这样两条折线因为有相同路径会重叠在一起,空白的这段就会被第二条虚线折线补足

// 把 d.y 大於零的资料拉出来,另外用这些资料去建立连线
let filteredData = data.filter(d => d.y > 0); //或是用 lineChart.defined()

// 建立 dashed 折线
svg.append('g')
   .append('path')
   .attr("fill", "none")
   .attr("stroke", 'green')
   .attr("stroke-width", 1.5)
   .attr("stroke-dasharray", '4,4')
   .attr('d',lineChart(filteredData))

完成的图表就会像这样子
https://ithelp.ithome.com.tw/upload/images/20211007/20134930AMvC7c1UHR.jpg

这样还不够,我们还要加上圆点点 跟 hover 时的 tooltips

// 加上 tooltip
    const tooltip = d3.select('.definedLineChart')
                      .append('div')
                      .style('position', 'absolute')
                      .style("opacity", 0)
                      .style("background-color", "white")
                      .style("border", "1px solid black")
                      .style("border-radius", "5px")
                      .style("padding", "5px")
    
    // 加上圆点点
    svg.append('g')
       .selectAll('circle')
       .data(filteredData)
       .join('circle')
       .attr('r', '5')
       .attr('cx', d => xScale(d.x))
       .attr('cy', d => yScale(d.y))
       .attr('fill', 'white')
       .attr('stroke', '#2a8e36')
       .attr('stroke-width', '2')
       .style('cursor', 'pointer')
       .on('mouseover', dotsMouseover)
       .on('mouseleave', dotsMouseleave)

    function dotsMouseover(d){
      const pt = d3.pointer(event, svg.node())
      tooltip.style("opacity", 1)
             .style('left', (pt[0]+20) + 'px')
             .style('top', (pt[1]) + 'px')
             .html(`<p>月份: ${d.target.__data__.x}月</p>`+
                   `<span>数值: ${d.target.__data__.y}</span>`)
    }

    function dotsMouseleave(){
      tooltip.style('opacity', 0)
    }

现在的图表
https://ithelp.ithome.com.tw/upload/images/20211007/20134930Ss0swrHOCL.jpg

最後的最後,我们要加上 hover 时的对齐轴线

function dotsMouseover(d){
      const pt = d3.pointer(event, svg.node())
      tooltip.style("opacity", 1)
             .style('left', (pt[0]+20) + 'px')
             .style('top', (pt[1]) + 'px')
             .html(`<p>月份: ${d.target.__data__.x}月</p>`+
                   `<span>数值: ${d.target.__data__.y}</span>`)

      // 加上 X-dashed 线
      svg.append('line')
          .attr('class', 'dashed-X')
          .attr('x1', xScale(d.target.__data__.x))
          .attr('y1', margin) // yScale(d.target.__data__.y) 会截断超过点位置的线
          .attr('x2', xScale(d.target.__data__.x))
          .attr('y2', height-margin) 
          .style('stroke', 'grey')
          .style('stroke-dasharray', '4' )

      // 加上 Y-dashed 线
      svg.append('line')
          .attr('class', 'dashed-Y')
          .attr('x1', margin)
          .attr('y1', yScale(d.target.__data__.y))
          .attr('x2', width-margin) // xScale(d.target.__data__.x) 会截断超过点位置的线
          .attr('y2', yScale(d.target.__data__.y))
          .style('stroke', 'grey')
          .style('stroke-dasharray', '4' )
    }

    function dotsMouseleave(){
      tooltip.style('opacity', 0)
      svg.selectAll('.dashed-X').remove()
      svg.selectAll('.dashed-Y').remove()
    }

完成!!!! 现在的图表就像这样
https://ithelp.ithome.com.tw/upload/images/20211007/20134930jMGjLRoy5Z.jpg

我的天啊终於写完了,虽然折线图很简单,但是要加上这些互动效果就复杂很多~我自己觉得这些互动是很常见、很能帮助使用者查看图表时使用的功能,但自己也查了很多资料才知道要怎麽实践。希望这边写下来後,未来的人就不用辛辛苦苦查询各种资料了!


Github Page 图表与 Github 程序码

最後附上本章的程序码:想看完整程序码的请上 Github,想直接操作图表的则去 Github Page 吧!请自行取用~


<<:  day22 热流sharedFlow

>>:  【day22】FCM云端通讯测试

let / const 细节版

var / let / const var 关键字,声明一个 function scope 变数 l...

Day12 Let's ODOO: Security(1) Access right

藉由ODOO的security,进行对model的权限设定,我们今天来写一个student 权限的范...

创建App-学生版界面(1.课程资料界面)

学生版界面(课程资料界面) 现在进入学生版的界面建设,因学生版本只能接收由老师透过本App发布的课程...

Day30 完赛心得

连续30天的铁人赛终於要完结啦! 这次参赛主题"Elastic Stack(ELK)数据图...

Framebuffer

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 ...