本篇大綱:範例圖表一、範例圖表二
昨天看完了單線折線圖怎麼繪製,今天我們就來看看多線折線圖
吧!
有時候我們拿到的資料會分成很多組,需要去比較不同組別的數據,這時候就可以使用這種多條線的折線圖去繪製,不但能看出單項目的數據,還能比較不同項目的數據差異。因此我們今天就用兩種不同互動效果的範例來練習畫多線折線圖吧!
為了跟真實世界接軌,我們這次也使用政府的開放資料來繪製折線圖~這次要使用的是各觀測站的降雨量資料,這裡有提供 json檔跟 csv 檔,之前我們已經用 csv 檔繪製過圖表了,這次就換成用 json 檔吧!我們先找到資料 json檔的網址,打開來後資料結構是這樣
接著我們先來看看第一張圖表的結構與互動效果
現在開始來繪製圖表吧!一樣先取資料並建立 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]) // 只取資料的部分帶入
})
這樣最基本的多線折線圖就完成囉
再來我們要在線段上綁定滑鼠事件來顯示 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')
}
完成~最終結果就是這樣
除了滑鼠事件之外,多線折線圖更常搭配的互動效果是放大縮小。由於多線折線圖的折線通常很密集,為了方便使用者查找某個時期的資料,我們往往會搭配刷子選取+放大的效果去繪製圖表
這個範例比較難一點,會運用到 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 方法。這邊很重要,也是縮放的關鍵!
// 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]) // 只取資料的部分帶入
})
})
這樣就能順利完成啦!
這幾篇寫完後,基礎的圖表就講得差不多了,而且還包含了不少進階的互動功能~明天開始就要進入進階一點的圖表啦!希望可以平安順利的完結鐵人賽!!
最後附上本章的程式碼:想看完整程式碼的請上 Github,想直接操作圖表的則去 Github Page 吧!請自行取用~