iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Modern Web

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

Day26-D3 基礎圖表:多線折線圖

本篇大綱:範例圖表一、範例圖表二

昨天看完了單線折線圖怎麼繪製,今天我們就來看看多線折線圖吧!
https://ithelp.ithome.com.tw/upload/images/20211008/20134930Ykf3PNcYve.jpg

有時候我們拿到的資料會分成很多組,需要去比較不同組別的數據,這時候就可以使用這種多條線的折線圖去繪製,不但能看出單項目的數據,還能比較不同項目的數據差異。因此我們今天就用兩種不同互動效果的範例來練習畫多線折線圖吧!

本次範例使用的資料

為了跟真實世界接軌,我們這次也使用政府的開放資料來繪製折線圖~這次要使用的是各觀測站的降雨量資料,這裡有提供 json檔跟 csv 檔,之前我們已經用 csv 檔繪製過圖表了,這次就換成用 json 檔吧!我們先找到資料 json檔的網址,打開來後資料結構是這樣
https://ithelp.ithome.com.tw/upload/images/20211008/20134930Dqq1oRM5sC.jpg

範例一圖表與互動效果

接著我們先來看看第一張圖表的結構與互動效果

  • 資料抓2017年各月份的數據進行比較、不同組資料分成不同顏色
  • 滑過折線時,會顯示這條線代表哪個觀測站提供的數據
    https://i.imgur.com/HXaJEKZ.gif

範例一圖表繪製

現在開始來繪製圖表吧!一樣先取資料並建立 svg 元素

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

由於我只想抓2017年的資料,所以就先用 filter 的方法把不是2017年的資料都過濾掉,再用得到的資料去繪製圖表

// js
// 先取資料
let data = []
async function getData() {
  dataGet = await d3.json('https://data.coa.gov.tw/Service/OpenData/TransService.aspx?UnitId=5n9c3AlEJ2DH')
  data = dataGet.filter(d=>d.observeDate.substr(0,4)==='2017') // 只取2017的資料
  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 = 60

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

// 接下來的程式碼放這邊...
// 接下來的程式碼放這邊...
// 接下來的程式碼放這邊...

}

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

然後我們要把 XY軸需要的資料分別抓出來。這邊要注意的是有些月份沒有降雨量資料,我們要先把這些沒有資料的部分轉成0,否則建立軸線時會出錯

// map 資料集
const xData = data.map((i) => i.observeDate.substr(5,6));
const yData = data.map((i)=>{
  let rainfall = parseFloat(i.rainfall)
  return rainfall = rainfall || 0 // 沒有的資料換成0
})

接下來用整理好的資料去建立比例尺跟XY軸

// 設定要給 X 軸用的 scale 跟 axis
const xScale = d3.scaleLinear()
                .domain(d3.extent(xData))
                .range([margin, rwdSvgWidth - margin]) // 寬度
                .nice()

// rwd X軸的刻度
const xAxis = d3.axisBottom(xScale)
                .ticks(8)
                .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(d3.extent(yData))
                .range([rwdSvgHeight - margin, margin]) // 數值要顛倒,才會從低往高排
                .nice()

const yAxis = d3.axisLeft(yScale)

// 呼叫繪製y軸、調整y軸位置
const yAxisGroup = svg.append("g")
                      .call(yAxis)
                      .attr("transform", `translate(${margin},0)`)

再來是關鍵!我們要建立分組的組別,把哪些資料是同一組(同一觀測站)的抓出來,這個就是用來建立不同條折線

// 把資料按照 name 分組
const sumName = d3.group(data, d => d.observatory);

之後用 d3 提供的 d3.schemeCategory10 方法設定折線的顏色,如果不知道這是什麼以及要怎麼設定,歡迎去看我的 D3 Scale篇章

const color = d3.scaleOrdinal()
                .domain(data.map(d=>d.item))
                .range(d3.schemeCategory10);

都設定好後就是建立折線圖的階段啦!

// 開始建立折線圖
const line = svg.selectAll('.line')
					  .data(sumName)
					  .join('path')
					  .attr("fill", "none")
					  .attr("stroke", d => color(d))
					  .attr("stroke-width", 1.5)
					  .attr("d", d => {
					      return d3.line()
					        .x((d) => xScale(d.observeDate.substr(5,6)))
					        .y((d) => {
					          let rainfall = parseFloat(d.rainfall)
					          rainfall = rainfall || 0
					          return yScale(rainfall)
					          })
					        (d[1]) // 只取資料的部分帶入
					    })

這樣最基本的多線折線圖就完成囉
https://ithelp.ithome.com.tw/upload/images/20211008/20134930XE6y460YSe.jpg

再來我們要在線段上綁定滑鼠事件來顯示 tooltips。其實綁在線段上不是很明智的選擇,應該要另外建立顏色標籤去標明每條線代表哪個觀測站,因為線段太細了,使用者很難準確的滑到線段上。不過這邊只是想示範折線圖的滑鼠事件,所以就先綁吧!

我們要先建立 tooltips標籤,如果不知道什麼是 tooltips,歡迎去看我的另一篇 tooltips 文章

// 建立浮動的資料標籤
const nameTag = d3.select('.chart')
                .append('div')
                .attr('class', 'nameTag')
                .style('position', 'absolute')
                .style("opacity", 0)
                .style("background-color", "black")
                .style("border-radius", "5px")
                .style("padding", "10px")
                .style("color", "white")

最後把線段綁定上滑鼠事件,並且滑過時顯示tooltips。tooltips 內容是綁定在 _data_ 內的觀測站名稱資料,位置則是用 d3.pointer( )去設定。

line.style('cursor', 'pointer')
  .on('mouseover', handleMouseover)

// 滑鼠事件
function handleMouseover(d){
  const pt = d3.pointer(event, this)
  // console.log(pt) 
  // console.log(d.target.__data__[0])

  nameTag.style("opacity",1)
         .html(d.target.__data__[0])
         .style('left', (pt[0]+10) + 'px')
         .style('top', (pt[1]+ 10) + 'px')
}

完成~最終結果就是這樣
https://i.imgur.com/HXaJEKZ.gif

多線折線圖範例二

除了滑鼠事件之外,多線折線圖更常搭配的互動效果是放大縮小。由於多線折線圖的折線通常很密集,為了方便使用者查找某個時期的資料,我們往往會搭配刷子選取+放大的效果去繪製圖表
https://i.imgur.com/V81QKT4.gif

這個範例比較難一點,會運用到 d3.brush 的功能,如果不清楚該怎麼使用 d3.brush的話,歡迎去看我的 d3.brush 篇章。現在我們開始吧!

這次因為有縮放功能,因此我決定把兩年的所有資料都納入,不像範例一只取2017年的資料。

// css
.chart2{
    width: 100%;
    min-width: 300px;
    margin: auto;
    position:relative;
}

// html
<div class="chart2"></div>
let dataTime = []
async function getDataTime() {
  dataGet = await d3.json('https://data.coa.gov.tw/Service/OpenData/TransService.aspx?UnitId=5n9c3AlEJ2DH')
  data = dataGet
  drawTimeChart()
};
getDataTime()

// RWD
function drawTimeChart(){
  // 刪除原本的svg.charts,重新渲染改變寬度的svg
  d3.select('.chart2 svg').remove();

  // RWD 的svg 寬高
  const rwdSvgWidth = parseInt(d3.select('.chart2').style('width')),
        rwdSvgHeight = rwdSvgWidth*0.8,
        margin = 60

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

// 接下來的程式碼放這邊...
// 接下來的程式碼放這邊...
// 接下來的程式碼放這邊...

}

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

這次的X軸繪製方式跟上一個範例有點不同,我們改成使用 scaleTime 的方法來建立。如果不清楚不同 scale 的設定,請見我這一篇關於 scale的文章。因為要使用 scaleTime,我們要先設定一個方法把日期用 d3.timeParse( ) 轉成d3能夠讀懂的數據,如果不知道該怎麼用 d3.timeParse( ),請看這篇文章

// 設定 format 時間的方法
function parseTime(d){
  return d3.timeParse("%Y-%m")(d)
}

接著我們先把XY軸需要的資料分別整理出來,同時整理要分組的資料、設定折線顏色

// map 資料集
const xData = data.map((i) => i.observeDate);
const yData = data.map((i)=>{
  let rainfall = parseFloat(i.rainfall)
  return rainfall = rainfall || 0 // 沒有的資料換成0
})

// 把資料按照 name 分組
const sumName = d3.group(data, d => d.observatory);

// 設定軸線顏色
const color = d3.scaleOrdinal()
                    .domain(data.map(d=>d.item))
                    .range(d3.schemeCategory10);

再來就是關鍵!X軸的部分要用剛剛設定好的 parseTime 方法,把 xData 資料帶入 d3.scaleTime( ) 中建立比例尺,再依此繪製XY軸

// 設定要給 X 軸用的 scale 跟 axis
const xScale = d3.scaleTime()
                .domain(d3.extent(data, d => parseTime(d.observeDate)))
                .range([margin, rwdSvgWidth - margin]) // 寬度

// rwd X軸
const xAxis = d3.axisBottom(xScale)
                // .tickFormat(d3.timeFormat('%b')) // 只顯示縮寫月份

const xAxisGroup = svg.append("g")
                      .call(xAxis)
                      .attr("transform", `translate(0,${rwdSvgHeight - margin})`)

// 設定要給 Y 軸用的 scale 跟 axis
const yScale = d3.scaleLinear()
                .domain(d3.extent(yData))
                .range([rwdSvgHeight - margin, margin]) // 數值要顛倒,才會從低往高排
                .nice()

const yAxis = d3.axisLeft(yScale)

// 呼叫繪製y軸、調整y軸位置
const yAxisGroup = svg.append("g")
                      .call(yAxis)
                      .attr("transform", `translate(${margin},0)`)

再來也是關鍵!因為我們要讓使用者用 brush 選定範圍後縮放圖表,所以要建立一個在XY軸內的範圍,確保圖表縮放後不會超過XY軸,然後把折線圖跟 brush 都綁定在這個範圍內

// 建立一個畫布範圍,超過此畫布的畫面都不會被渲染,這樣才能控制縮放的大小
const clip = svg.append("defs")
            .append("clipPath")
            .attr("id", "clip")
            .append("rect")
            .attr("x", margin)
            .attr("y", margin)
            .attr("width", rwdSvgWidth-margin*2)
            .attr("height", rwdSvgHeight-margin*2)

// 加上brush
const brush = d3.brushX()
                .extent([[margin, margin], [rwdSvgWidth-margin, rwdSvgHeight-margin]])
                .on("end", updateChart)

// 開始建立折線圖
const line = svg.append('g')
                .attr("clip-path", "url(#clip)")
line.selectAll('.line')
  .data(sumName)
  .join('path')
  .attr('class', 'line')
  .attr("fill", "none")
  .attr("stroke", d => color(d))
  .attr("stroke-width", 1.5)
  .attr("d", d => {
      return d3.line()
        .x((d) => xScale(parseTime(d.observeDate)))
        .y((d) => {
          let rainfall = parseFloat(d.rainfall)
          rainfall = rainfall || 0
          return yScale(rainfall)
          })
        (d[1]) // 只取資料的部分帶入
    })

// add brush
line.append('g')
    .attr('class', 'brush')
    .call(brush)

接著要設定放開刷子後要進行的 updateChart 方法。這邊很重要,也是縮放的關鍵!

  • 我們先設定一個 extent 變數,它是brush刷取之後會返還的範圍
  • 把 X軸比例尺的 domain 設定成brush的這個範圍,這樣就能進行縮放。這邊要用到 d3.invert( ) 的方法,把我們得到的brush範圍值轉換成原本 xScale使用的數據
  • 接著brush結束後,要移除brush的灰色選取區塊
  • 最後重新渲染一次X軸跟折線圖們,讓它們依照新設定的比例尺去重新渲染,就能得到放大後的圖表了
// brush end function
function updateChart(event, d){
  // brush 的範圍,會返還一個[x0, x1]的陣列
  extent = event.selection

  if(extent){
    // xScale.invert 是把返還的x0跟x1變成xscale接受的數值
    xScale.domain([xScale.invert(extent[0]), xScale.invert(extent[1]) ])
    // 移除brush的灰色區塊
    line.select(".brush").call(brush.move, null)
  }

  // 按照更新的domain範圍值重新渲染圖表
  xAxisGroup.transition().duration(1000).call(d3.axisBottom(xScale))
  line
      .selectAll('.line')
      .transition()
      .duration(1000)
      .attr("d", d => {
        return d3.line()
          .x((d) => xScale(parseTime(d.observeDate)))
          .y((d) => {
            let rainfall = parseFloat(d.rainfall)
            rainfall = rainfall || 0
            return yScale(rainfall)
            })
          (d[1]) // 只取資料的部分帶入
      })
  }

最後,圖表一直不停放大也不是辦法,我們希望雙擊svg時可以縮回原本的比例,因此要設定svg被雙擊時,xScale會回到原本設定的比例尺,並且一樣要重新渲染一次X軸跟折線圖

//雙擊 svg 縮回原本大小
svg.on('dblclick', function(){
  // 回到原本的大小
  xScale.domain(d3.extent(data, d => parseTime(d.observeDate)))

  // 重新呼叫渲染軸線跟折線
  xAxisGroup.transition().duration(1000).call(d3.axisBottom(xScale))
  line
      .selectAll('.line')
      .transition()
      .duration(1000)
      .attr("d", d => {
        return d3.line()
          .x((d) => xScale(parseTime(d.observeDate)))
          .y((d) => {
            let rainfall = parseFloat(d.rainfall)
            rainfall = rainfall || 0
            return yScale(rainfall)
            })
          (d[1]) // 只取資料的部分帶入
      })
})

這樣就能順利完成啦!
https://i.imgur.com/V81QKT4.gif

這幾篇寫完後,基礎的圖表就講得差不多了,而且還包含了不少進階的互動功能~明天開始就要進入進階一點的圖表啦!希望可以平安順利的完結鐵人賽!!


Github Page 圖表與 Github 程式碼

最後附上本章的程式碼:想看完整程式碼的請上 Github,想直接操作圖表的則去 Github Page 吧!請自行取用~


上一篇
Day25-D3 基礎圖表:折線圖+ d3.bisector( )與 d3.defined( )
下一篇
Day27-D3 進階圖表:甜甜圈圖
系列文
三十天成為D3.js v7 好手30

尚未有邦友留言

立即登入留言