iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Modern Web

三十天成為D3.js v7 好手系列 第 25

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 吧!請自行取用~


上一篇
Day24-D3 基礎圖表:堆疊長條圖
下一篇
Day26-D3 基礎圖表:多線折線圖
系列文
三十天成為D3.js v7 好手30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言