Skip to content

我给中国🇨🇳奥运🏅数做了可视化 #13

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wzf1997 opened this issue Aug 8, 2021 · 0 comments
Open

我给中国🇨🇳奥运🏅数做了可视化 #13

wzf1997 opened this issue Aug 8, 2021 · 0 comments

Comments

@wzf1997
Copy link
Owner

wzf1997 commented Aug 8, 2021

前言

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()

然后当我很兴奋的想要去结果的时候,结果发现是空。百度是不是做了反爬虫协议, 毕竟我是爬虫菜鸟,搞了很久。还是没搞出来。如果有大佬会,欢迎指点我下哦!

image-20210731112152170

不过这个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)
    })
}

数据如下图所示 :

image-20210731114644399

柱状图的表示

其实我想了很多表达中国金牌数的方式,最终我还是选择用2d柱状图去表示,并同时做了动画效果,显得每一快金牌🏅来的并不容易。我还是用原生手写柱状图不去使用Echarts 库, 我们首先先看下柱状图:

柱状图

从图中可以分析出一些元素

  1. x轴和y轴以及一些直线,所以我只要封装一个画直线的方法
  2. 有很多矩形, 封装一个画矩形的方法
  3. 还有一些刻度和标尺
  4. 最后就是一进入的动画效果

画布初始化

在页面上创建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画笔的样式的,其实这东西没什么。 我们看下效果:

X轴和Y轴

很多人以为到这里就结束了哈哈哈, 那你想太多了, 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平移

说的更直白点, 你对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)

偏移完之后还是要恢复过去的, 还是要十分注意的。 我画了两张图作比对:

A偏移后 B偏移前

不多说了, 看到这里,如果觉得对你有帮助的话, 或者学到了话, 我是希望你给我点赞👍、评论、加收藏。

画标尺

我们现在只有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轴标尺

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)

最终呈现的效果图如下:

xy轴

画矩形

everything isReady, 下面开始画矩形, 还是同样的方式 先封装画矩形的方法, 然后我们只要传入对应的数据就OK了。

这里用到了,canvas原生的rect 方法。参数理解如下:

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)  }}

画出的效果图如下:

奖牌数

矩形交互优化

黑秃秃的也丑了吧,一个不知道的人根本不知道这是哪一个国家获得多少快金牌。

  1. 给矩形加一个渐变
  2. 加一些文字

现在画矩形的基础上加一些文字吧,代码如下:

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 = 1this.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图吧:

奥运gif图

总结

本篇文章写到这里也算结束了,我大概总结下:

  1. canvas如何画出1px 的直线, 这里面是有坑的
  2. 还有就是如何进行动画的设计,本质去寻找那些变的,然后去处理就好了
  3. canvas 中如何进行线性渐变的。
  4. 爬虫我是失败了,我就没啥好总结的,不过有一点: 木偶人这个库, 大家可以玩一下的。

本篇文章算是canvas实现可视化图表的第二篇吧,后面我会持续分享、饼图、树状图、K线图等等各种可视化图表,我自

己在写文章的同时也在不断地思考,怎么去表达的更好。如果你对可视化感兴趣,点赞收藏关注👍吧!,可以关注我下

面的数据可视化专栏, 每周分享一篇 文章, 要么是2d、要么是three.js的。我会用心创作每一篇文章,绝不水文。

我们一起为中国🇨🇳奥运加油! 奥利给!!!

源码获得

关注公众号【前端图形】, 回复【奥运】 两个字,就可以获得所有源码。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant