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


<<:  【day23】存local端 帐号 (SharedPreferences)

>>:  [Day 24] -『 GO语言学习笔记』- 复合型别 - 阵列(Array) (II)

以Postgresql为主,再聊聊资料库 PostgreSQL Event Trigger 初探

PostgreSQL Event Trigger 初探 什麽是Event Trigger? 这里的e...

[Angular] Day26. Reactive forms (二)

上一篇中介绍了如何使用 FormControl 建立单个表单控制元件,也介绍了如何使用 FormGr...

企划实现(11)

FB登入 以上功能都完成後就要开始环境的建置了 第一步:下载android studio sdk(如...

CMoney工程师战斗营weekly10

本周专题发表成功! 先附上我本人制作的精美海报 分享一下我跟partner 忙了一个月的成果~ 去年...

[Day 5] Leetcode 322. Coin Change (C++)

前言 天啊今天整个非常赶,23:00到家打开发现今天是hard的题目(446. Arithmetic...