前言
2020東京奧運會已經開幕很多天了,還記得小時候看奧運會的是在2008年的北京奧運會,主題曲是北京歡迎你, 那個時候才上小學吧,幾乎有中國隊的每場必看,當時也是熱血沸騰了, 時間轉眼已經到了2021年而我也從小學生變成了一個每天不斷敲程式碼的程式設計師??,看奧運的時間又少,但是又想出分力,既然是程式設計師,想著能為奧運會搞點什麼?第一時間想到了就是給奧運獎牌數?做視覺化,因為單看錶格資料,不能體現出我們中國的牛逼?, 廢話不多說,直接開寫。
資料獲得
我們先看下奧運獎牌數的表格,這東西肯定是介面獲得的吧,我不可能手寫吧,而且每天都是更新的,難道我要每天去改,肯定不是這樣的,我當時腦子裡就想著去做爬蟲,去用puppeteer 去模擬瀏覽器的行為然後獲取頁面的原生dom,然後將表格的資料搞出來, 然後我就很興奮的去搞了,寫了下面的程式碼:
const puppeteer = require('puppeteer')
async function main() {
// 啟動chrome瀏覽器
const browser = await puppeteer.launch({
// // 指定該瀏覽器的路徑
// executablePath: chromiumPath,
// 是否為無頭瀏覽器模式,預設為無頭瀏覽器模式
headless: false,
})
// 在一個預設的瀏覽器上下文中被建立一個新頁面
const page1 = await browser.newPage()
// 空白頁剛問該指定網址
await page1.goto(
'https://tiyu.baidu.com/tokyoly/home/tab/%E5%A5%96%E7%89%8C%E6%A6%9C/from/pc'
)
// 等待title節點出現
await page1.waitForSelector('title')
// 用page自帶的方法獲取節點
// 用js獲取節點
const titleDomText2 = await page1.evaluate(() => {
const titleDom = document.querySelectorAll('#kw')
return titleDom
})
console.log(titleDomText2, '檢視資料---')
// 截圖
//await page1.screenshot({ path: 'google.png' })
// await page1.pdf({
// path: './baidu.pdf',
// })
browser.close()
}
main()
然後當我很興奮的想要去結果的時候,結果發現是空。百度是不是做了反爬蟲協議, 畢竟我是爬蟲菜鳥,搞了很久。還是沒搞出來。如果有大佬會,歡迎指點我下哦!
不過這個puppeteer,這個庫有點牛皮的,可以實現網頁截圖、生成pdf、攔截請求,其實有點自動化測試的感覺。感興趣的同學可以自行了解一下,這不在本篇文章介紹的重點。
介面獲得
然後這時候就開始瘋狂百度,開始尋找有沒有現成的api, 真是踏破鐵鞋無覓處,得來全不費工夫。被我找到了,原來是有大佬已經開始做了, 這時候我本地直接去請求那個介面是有問題的,前端不得不處理的問題—— 跨域。 看著東西我頭疼哇, 不過沒關係, 我直接node起一個伺服器, 我node去請求那個介面,然後後臺在配置下跨域, 搞定介面資料就直接獲得了, 後臺服務我是用的express, 搭建的伺服器直接隨便搞搞的。程式碼如下:
const axios = require('axios')
const express = require('express')
const request = require('request')
const app = express()
const allowCrossDomain = function (req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
res.header('Access-Control-Allow-Headers', 'Content-Type')
res.header('Access-Control-Allow-Credentials', 'true')
next()
}
app.use(allowCrossDomain)
app.get('/data', (req, res) => {
request(
{
url: 'http://apia.yikeapi.com/olympic/?appid=43656176&appsecret=I42og6Lm',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
function (error, response, body) {
if (error) {
res.send(error)
} else {
res.send(response)
}
}
)
})
app.listen(3030)
這樣我就是實現了介面轉發,也搞定了跨域問題,前臺我直接用 fetch去請求資料然後做一層資料轉換,但是這個介面不能頻繁請求,動不動就crash, 是真的煩, OK所以直接做了一個操作, 將資料 存到localstorage中,然後做一個定時重新整理,時間大概是一天一刷。這樣就保證資料的有效性。程式碼如下:
getData() {
let curTime = Date.now()
if (localStorage.getItem('aoyun')) {
let { list, time } = JSON.parse(localStorage.getItem('aoyun'))
console.log(curTime - time, '檢視時間差')
if (curTime - time <= 24 * 60 * 60 * 60) {
this.data = list
} else {
this.fetchData()
}
} else {
this.fetchData()
}
}
fetchData() {
fetch('http://localhost:3030/data')
.then((res) => res.json())
.then((res) => {
const { errcode, list } = JSON.parse(res.body)
if (errcode === 100) {
alert('介面請求太頻繁')
} else if (errcode === 0) {
this.data = list
const obj = {
list,
time: Date.now(),
}
localStorage.setItem('aoyun', JSON.stringify(obj))
}
})
.catch((err) => {
console.log(err)
})
}
資料如下圖所示 :
柱狀圖的表示
其實我想了很多表達中國金牌數的方式,最終我還是選擇用2d柱狀圖去表示,並同時做了動畫效果,顯得每一快金牌?來的並不容易。我還是用原生手寫柱狀圖不去使用Echarts 庫, 我們首先先看下柱狀圖:
從圖中可以分析出一些元素
- x軸和y軸以及一些直線,所以我只要封裝一個畫直線的方法
- 有很多矩形, 封裝一個畫矩形的方法
- 還有一些刻度和標尺
- 最後就是一進入的動畫效果
畫布初始化
在頁面上建立canvas和獲取canvas的一些屬性,並對canvas綁上移動事件。程式碼如下:
get2d() {
this.canvas = document.getElementById('canvas')
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.ctx = this.canvas.getContext('2d')
this.width = canvas.width
this.height = canvas.height
}
畫座標軸
座標軸本質上也是一個直線,直線對應的兩個點,不同的直線其實就是對應的端點不同,所以我直接封裝了一個畫直線的方法:
// 畫線的方法
drawLine(x, y, X, Y) {
this.ctx.beginPath()
this.ctx.moveTo(x, y)
this.ctx.lineTo(X, Y)
this.ctx.stroke()
this.ctx.closePath()
}
可能有的人對canvas不熟悉,這裡我還是大概說下, 開啟一段路徑, 移動畫筆到開始的點, 然後畫直線到末尾的點,然後描邊 這一步是canvas做渲染, 很重要,很多小白不寫, 直線就不出來, 然後閉合路徑。 結束over!
畫座標軸我們首先先確定原點在哪裡,我們首先給畫布向內縮一個padding距離,然後呢,算出畫布實際的寬度和高度。
程式碼如下:
initChart() {
// 留一個內邊距
this.padding = 50
// 算出畫布實際的寬度和高度
this.cHeight = this.height - this.padding * 2
this.cWidth = this.width - this.padding * 2
// 計算出原點
this.originX = this.padding
this.originY = this.padding + this.cHeight
}
有了原點我們就可以畫X軸和Y軸了, 只要加上實際畫布對應的寬度和高度 就好了 。 程式碼如下:
//設定canvas 樣式
this.setCanvasStyle()
// 畫x軸
this.drawLine(
this.originX,
this.originY,
this.originX,
this.originY - this.cHeight
)
// 畫Y軸
this.drawLine(
this.originX,
this.originY,
this.originX + this.cWidth,
this.originY
)
第一個 函式就是設定canvas畫筆的樣式的,其實這東西沒什麼。 我們看下效果:
很多人以為到這裡就結束了哈哈哈, 那你想太多了, canvas我設定的畫線寬度是1px 為什麼看圖片的線的寬度像是2px?不仔細觀察根本發現不了這個問題, 所以我們要學會思考這到底是什麼問題?其實這個問題也是我看Echarts原始碼發現的, 學而不思則罔,思而不學則殆哇!
彩蛋——canvas如何畫出1PX的直線
在這裡我舉一個例子, 你就明白了, 假設我要畫從(50,10) 到 (200,10)這樣的一條直線。為了畫這條線,瀏覽器首先到達初始起點(50,10)。這條線寬1px,所以兩邊各留0.5px。所以基本上初始起點是從(50,9.5)延伸到(50,10.5)。現在瀏覽器不能在螢幕上顯示0.5畫素——最小閾值是1畫素。瀏覽器別無選擇,只能將起點的邊界延伸到螢幕上的實際畫素邊界。它會在兩邊再加0.5倍的“垃圾”。所以現在,最初的起點是從(50,9)擴充套件到(50,11),所以看起來有2px寬。情況如下:
現在你就應該明白了原來瀏覽器不能顯示0.5畫素哇, 四捨五入了, 知道了 問題我們就一定有解決方案
平移canvas
ctx.translate (x,y ) 這個方法:
translate()
方法, 將 canvas 按原始 x點的水平方向、原始的 y點垂直方向進行平移變換
如圖:
說的更直白點, 你對canvas做了translate變化後, 你之前所有畫的點,都會相對偏移。 所以呢,回到我們這個問題上來, 解決辦法就是什麼呢?就我將畫布 整體向下偏移 0.5 , 所以原本座標 (50,10) 變成了(50.5,10.5) 和(200.5, 10.5)ok 然後瀏覽器的再去畫的 他還是要預留畫素, 所以就是從(50.5, 10) 到(50.5, 11) 這個區間去畫OK, 就是1px了。我們來try it.
程式碼如下:
this.ctx.translate(0.5, 0.5)
// 畫x軸
this.drawLine(
this.originX,
this.originY,
this.originX,
this.originY - this.cHeight
)
// 畫Y軸
this.drawLine(
this.originX,
this.originY,
this.originX + this.cWidth,
this.originY
)
this.ctx.translate(-0.5, -0.5)
偏移完之後還是要恢復過去的, 還是要十分注意的。 我畫了兩張圖作比對:
偏移後 偏移前
不多說了, 看到這裡,如果覺得對你有幫助的話, 或者學到了話, 我是希望你給我點贊?、評論、加收藏。
畫標尺
我們現在只有X軸和Y軸, 光禿禿的,我給X軸和Y軸底部增加一些標尺,X軸對應的標尺,肯定就是每個國家的名字,大概的思路就是資料的數量去做一個分段, 然後去填充就好了。
程式碼如下:
drawXlabel() {
const length = this.data.slice(0, 10).length
this.ctx.textAlign = 'center'
for (let i = 0; i < length; i++) {
const { country } = this.data[i]
const totalWidth = this.cWidth - 20
const xMarker = parseInt(
this.originX + totalWidth * (i / length) + this.rectWidth
)
const yMarker = this.originY + 15
this.ctx.fillText(country, xMarker, yMarker, 40) // 文字
}
}
這裡的話我擷取了排名前10的國家, 分斷的思路, 首先兩邊留白20px, 我們首先先定義每一個柱狀圖的寬度 假設是 30 對應上文的 this.rectWidth, 然後每個文字的座標 其實就很好算了, 起初的x + 所佔的分端數 + 矩形寬度就可以畫出來了
如圖:
x軸畫完了,我們開始畫Y軸, Y軸的大概思路就是 以最多的獎牌數去做分段, 這裡我就分成6段吧。
// 定義Y軸的分段數
this.ySegments = 6
//定義字型最大寬度
this.fontMaxWidth = 40
接下啦我們就開始計算Y軸每個點的Y座標, X座標其實很好計算 只要原點座標的X向左平移幾個距離就好了,主要是計算Y軸的座標, 這裡一定要注意的是, 我們從座標是相對於左上角的, 所以呢, Y軸的座標應該是向上遞減的。
drawYlabel() {
const { jin: maxValue } = this.data[0]
this.ctx.textAlign = 'right'
for (let i = 1; i <= this.ySegments; i++) {
const markerVal = parseInt(maxValue * (i / this.ySegments))
const xMarker = this.originX - 5
const yMarker =
parseInt((this.cHeight * (this.ySegments - i)) / this.ySegments) +
this.padding +
20
this.ctx.fillText(markerVal, xMarker, yMarker) // 文字
}
}
最大的資料就是陣列的第一個資料, 然後每個標尺就是所佔的比例就好了, Y軸的座標由於我們是遞減的所以 對應的座標應該是 1- 所佔的份額, 由於這只是算的圖示的實際高度 ,換算到畫布裡面, 還要加上原先我們設定的內邊距,由於又加上了文字, 文字也佔有一定畫素, 所以有加上了20。 OK Y軸畫結束了, 有了Y軸每個分斷的座標, 同時就畫出背後的對應的幾條實線。
程式碼如下:
this.drawLine(
this.originX,
yMarker - 4,
this.originX + this.cWidth,
yMarker - 4
)
最終呈現的效果圖如下:
畫矩形
everything isReady, 下面開始畫矩形, 還是同樣的方式 先封裝畫矩形的方法, 然後我們只要傳入對應的資料就OK了。
這裡用到了,canvas原生的rect 方法。引數理解如下:
矩形寬度 我們自定義的, 矩形的高度就是對應的獎牌數在畫布中的高度, 所以我們只要確定 矩形的起點就搞定了, 這裡矩形的(x,y) 其實是左上角的點。
程式碼如下:
//繪製方塊
drawRect(x, y, width, height) {
this.ctx.beginPath()
this.ctx.rect(x, y, width, height)
this.ctx.fill()
this.ctx.closePath()
}
第一步我們先做一個點的對映, 我們在畫Y軸的時候,將Y軸的上的畫布的所有的點都放在一個陣列中, 注意記得將原點的Y放進去。所以只要計算出每個獎牌數在總部的比例是多少? 然後再用原點的Y值做一個相減就可以得到真正的Y軸座標了。X軸的座標就比較簡單了,原點的X座標加上 ( 所佔的比例 / 總長度 ) 然後在加上 一半的矩形寬度就好了。 這個道理和畫文字是一樣的, 只不過文字要居中嘛。
程式碼如下:
drawBars() {
const length = this.data.slice(0, 10).length
const { jin: max } = this.data[0]
const diff = this.yPoints[0] - this.yPoints[this.yPoints.length - 1]
for (let i = 0; i < length; i++) {
const { jin: count } = this.data[i]
const barH = (count / max) * diff
const y = this.originY - barH
const totalWidth = this.cWidth - 20
const x = parseInt(
this.originX + totalWidth * (i / length) + this.rectWidth / 2
)
this.drawRect(x, y, this.rectWidth, barH)
}
}
畫出的效果圖如下:
矩形互動優化
黑禿禿的也醜了吧,一個不知道的人根本不知道這是哪一個國家獲得多少快金牌。
- 給矩形加一個漸變
- 加一些文字
現在畫矩形的基礎上加一些文字吧,程式碼如下:
this.ctx.save()
this.ctx.textAlign = 'center'
this.ctx.fillText(count, x + this.rectWidth / 2, y - 5)
this.ctx.restore()
漸變就設計到Canvas一個api了,createLinearGradient
createLinearGradient()
方法需要指定四個引數,分別表示漸變線段的開始和結束點。
那我就開始了首先肯定建立漸變:
getGradient() {
const gradient = this.ctx.createLinearGradient(0, 0, 0, 300)
gradient.addColorStop(0, 'green')
gradient.addColorStop(1, 'rgba(67,203,36,1)')
return gradient
}
然後呢我們就改造drawReact下 ,這裡用了 restore 和save 這個方法, 防止汙染文字的樣式。
//繪製方塊
drawRect(x, y, width, height) {
this.ctx.save()
this.ctx.beginPath()
const gradient = this.getGradient()
this.ctx.fillStyle = gradient
this.ctx.strokeStyle = gradient
this.ctx.rect(x, y, width, height)
this.ctx.fill()
this.ctx.closePath()
this.ctx.restore()
}
如圖所示:
新增動畫效果
光一個靜態的不能看出我們的牛皮?,所以得有動畫的效果慢慢的增加對吧。其實我們可以思考?下整個動畫過程,變化的其實就兩個, 柱狀圖的高度和文字, 其實座標軸, 以及柱狀圖的x座標是不變的, 所以我只要定義兩個變數一個開始的值 ,和一個總共的值,高度和文字的大小 其實在每一幀去乘以對應的高度就可以了。
程式碼如下:
// 運動相關
this.ctr = 1
this.numctr = 100
我們改造下drawBars 這個方法:
// 每一次的比例是多少
const dis = this.ctr / this.numctr
// 柱狀圖的高度 乘以對應的比例
const barH = (count / max) * diff * dis
// 文字這裡取整下,因為有可能除不盡
this.ctx.fillText(
parseInt(count * dis),
x + this.rectWidth / 2,
y - 5
)
// 最後執行動畫
if (this.ctr < this.numctr) {
this.ctr++
requestAnimationFrame(() => {
this.ctx.clearRect(0, 0, this.width, this.height)
this.drawLineLabelMarkers()
})
}
每一次都加一,直到比總數大, 然後不斷重畫。 就可以形成動畫效果了。我們看下gif圖吧:
總結
本篇文章寫到這裡也算結束了,我大概總結下:
- canvas如何畫出1px 的直線, 這裡面是有坑的
- 還有就是如何進行動畫的設計,本質去尋找那些變的,然後去處理就好了
- canvas 中如何進行線性漸變的。
- 爬蟲我是失敗了,我就沒啥好總結的,不過有一點: 木偶人這個庫, 大家可以玩一下的。
本篇文章算是canvas實現視覺化圖表的第二篇吧,後面我會持續分享、餅圖、樹狀圖、K線圖等等各種視覺化圖表,我自
己在寫文章的同時也在不斷地思考,怎麼去表達的更好。如果你對視覺化感興趣,點贊收藏關注?吧!,可以關注我下
面的資料視覺化專欄, 每週分享一篇 文章, 要麼是2d、要麼是three.js的。我會用心創作每一篇文章,絕不水文。
我們一起為中國??奧運加油! 奧利給!!!
原始碼獲得
關注公眾號【前端圖形】, 回覆【奧運】 兩個字,就可以獲得所有原始碼。