【建议收藏】数据可视化——从0-1实现折线图📈

4,595 阅读17分钟

前言

终于又到周末了,上一周的一篇3d文章 带你入门three.js——从0到1实现一个3d可视化地图很开心😺收到了这么多小伙伴的喜欢,这是对我知识输出的肯定。再次感谢大家!这周我又来了,这次给大家分享一下可视化图表比较简单的图表📈但同时我们又不得不学会的 那就是————折线图。读完本篇文章你可以学到什么

  1. js实现直线方程
  2. 折线图的表达
  3. canvas的一些api灵活的运用

直线折线图

我们先去非常有名的Echarts 官网看一看,他的折线图是什么样子的?如图:

echats折线图

从图中可以得到以下2d图形元素:

  1. 直线(两个端点是圆的)
  2. 直线(两个端点是直线的)
  3. 文字

好像仔细分析一下也没什么嘛,其实就是画直线和加文字。OK, 问下自己canvas如何画直线?是不是有一个ctx.LineTo的方法,但是他画出来的是直线没有端点的所以呢? 我们以此基础进行封装,并且直线的端点的图形可控, 同时还有文字位于直线的位置是不是可以画出这样的图形呢? 我们接下来进行实操环节。

画布的创建

第一步我们肯定是进行画布的创建,这里没什么好讲的。这里我在html 新建一个canvas, 我新建了一个类叫lineChart 直接上代码:

    class lineChart {
        constructor(data, type) {
          this.get2d()
        }

        get2d() {
          const canvas = document.getElementById('canvas')
          this.ctx = canvas.getContext('2d')
        }
      }

上面代码没什么好讲的,然后我在为canvas 画布设置背景色。代码如下:

    <style>
      * {
        padding: 0;
        margin: 0;
      }
      canvas {
        background: aquamarine;
      }
    </style>

canvas绘图操作复习

其实折线图,本质上就是一个画直线,只不过在原有画直线的能力上,给他做一些增强。我用一个画三角形的例子: 带你熟悉一下画线操作。

先看下api:

lineTo(x, y)

绘制一条从当前位置到指定x以及y位置的直线。

直线一般是由两个点组成的,该方法有两个参数:x以及y ,代表坐标系中直线结束的点。开始点和之前的绘制路径有关,之前路径的结束点就是接下来的开始点,等等。。。开始点也可以通过moveTo()函数改变。

moveTo 是什么就在画布中移动笔触, 也就是你开始画的第一个点,或者你可以想象一下在纸上作业,一支钢笔或者铅笔的笔尖从一个点到另一个点的移动过程。

moveTo(*x*, *y*)

将笔触移动到指定的坐标x以及y上。

介绍完毕, 开始实战环节:

drawtriangle() {
  this.ctx.moveTo(25, 25)
  this.ctx.lineTo(105, 25)
  this.ctx.lineTo(25, 105)
}

我们先移动一个点, 然后再画条直线, 然后再画条直线。 如果写到你认为结束了,你就错了

你还差一个很重要的一步就是画布描边或者是填充, 我刚开始学也会忘记这个

这里給大家整理下canvas 的整个画图流程

  1. 首先,你需要创建路径起始点。
  2. 然后你使用画图命令去画出路径。
  3. 之后你把路径封闭。
  4. 一旦路径生成,你就能通过描边或填充路径区域来渲染图形。

也就是我们刚才所做的一切只是在准备路径,所以我们需要描边或者填充来渲染图形, 我们来看下这两个api。

// 通过线条来绘制图形轮廓。
ctx.stroke() 
// 通过填充路径的内容区域生成实心的图形。
ctx.fill()

我们把填充加上去: 看下效果:

填充三角形

我们看下描边效果:

未闭合

你会发现为什么没有闭合?,代码是这样的:

this.moveTo(25, 25)
this.lineTo(105, 25)
this.lineTo(25, 105)
this.stroke()

这就说明了一个重要问题就是什么呢?

描边是默认不闭合的,需要我们手动闭合 填充默认会帮我们闭合图形, 并且填充

既然发现了问题,我们就需要解决问题,那么canvas 如何闭合路径呢??

closePath:

闭合路径之后图形绘制命令又重新指向到上下文中。

代码如下:

this.moveTo(25, 25)
this.lineTo(105, 25)
this.lineTo(25, 105)
this.closePath()
this.stroke()

这时候效果图已经出来了:

闭合三角形

有closePath? 难道没有开始路径? 答案是当然有的:

// 新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
this.beginPath()

这里会问这个有什么作用呢?

首先 生成路径的第一步叫做beginPath()。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形。

注意:当前路径为空,即调用beginPath()之后,或者canvas刚建的时候,第一条路径构造命令通常被视为是moveTo(),无论实际上是什么。出于这个原因,你几乎总是要在设置路径之后专门指定你的起始位置。

closePath 其实也不是必须的,如果图形已经是闭合的,就不需要调用, 到这里canvas的基本绘图操作复习就到这里,后面还有一些实战api : 我就例子中给大家讲解, 不然会显得很生硬。

封装画直线方法

再次之前,我把canvas中每一个点的位置都用一个point2d 点去表示并且写了一些方法,我在之前的文章都有仔细讲过这里我就不展开说了: 3千字长文canvas实现任意正多边形的移动(点、线、面) 这一篇文章。 这里我就直接放上代码:

export class Point2d {
  constructor(x, y) {
    this.x = x || 0
    this.y = y || 0
    this.id = ++current
  }
  clone() {
    return new Point2d(this.x, this.y)
  }

  equal(v) {
    return this.x === v.x && this.y === v.y
  }

  add2Map() {
    pointMap.push(this)
    return this
  }

  add(v) {
    this.x += v.x
    this.y += v.y
    return this
  }

  abs() {
    return [Math.abs(this.x), Math.abs(this.y)]
  }

  sub(v) {
    this.x -= v.x
    this.y -= v.y
    return this
  }

  equal(v) {
    return this.x === v.x && this.y === v.y
  }

  rotate(center, angle) {
    const c = Math.cos(angle),
      s = Math.sin(angle)
    const x = this.x - center.x
    const y = this.y - center.y
    this.x = x * c - y * s + center.x
    this.y = x * s + y * c + center.y
    return this
  }

  distance(p) {
    const [x, y] = this.clone().sub(p).abs()
    return x * x + y * y
  }

  distanceSq(p) {
    const [x, y] = this.clone().sub(p).abs()
    return Math.sqrt(x * x + y * y)
  }

  static random(width, height) {
    return new Point2d(Math.random() * width, Math.random() * height)
  }

  cross(v) {
    return this.x * v.y - this.y * v.x
  }
}

分别对应的是一些静态方法、叉乘、 两个点之间求距离哇等等。

我们先在画布上画一条基础的直线, 我们先用random, 在画布上重新生成两个点,然后画出一条随机的直线, 代码如下:

new lineChart().drawLine(
  Point2d.random(500, 500),
  Point2d.random(500, 500)
)
// 画直线
drawLine(start, end) {
  const { x: startX, y: startY } = start
  const { x: endX, y: endY } = end
  this.beginPath()
  this.moveTo(startX, startY)
  this.lineTo(endX, endY)
  this.stroke()
}

js实现直线方程

这里没有好展示的,我们还是分析下echarts 官方的折线图直线,直线两旁是两个圆的,想一想?其实这边涉及到一个数学知识,各位小伙伴,Fly再一次化身数学老师给大家讲解,主要是帮有些小伙伴复习复习。  这里我们已经知道直线的开始点和结束点,在数学中我们可以确定一条直线方程,那么我们就可以求出直线上任意一点的(x,y)坐标。那么直线的两个端点的圆心我们就可以确定? 半径也可以确定了就是圆心分别到开始点和结束点的距离。

第一步: 实现直线方程

我们先看下直线方程的几种表达方式:

  1. 一般式: Ax+By+C=0(A、B不同时为0)【适用于所有直线】

  2. 点斜式: y-y0=k(x-x0) 【适用于不垂直于x轴的直线 表示斜率为k,且过(x0,y0)的直线

  3. 截距式:x/a+y/b=1【适用于不过原点或不垂直于x轴、y轴的直线】

  4. 两点式:表示过(x1,y1)和(x2,y2)的直线 【适用于不垂直于x轴、y轴的直线】 (x1≠x2,y1≠y2)

    两点式两点式

这里很明显我们适合第四种:已经知道直线的起始点和结束点可以求出直线方程。我给出以下代码:

export function computeLine(p0, p1, t) {
  let x1 = p0.x
  let y1 = p0.y
  let x2 = p1.x
  let y2 = p1.y
  // 说明直线平行 y轴
  if (x1 === x2) {
    return new Point2d(x1, t)
  }
  // 平行X轴的情况
  if (y1 === y2) {
    return new Point2d(t, y1)
  }
  const y = ((t - x1) / (x2 - x1)) * (y2 - y1) + y1
  return new Point2d(t, y)
}

p0、p1、 对应的两个直线点 t 就是参数,对应直线的x,我们求出y,返回新的点就好了 。我们默认以开始点和结束点的 x 位置分别 减去或者加一个固定的值 , 求得圆心。直接看下图吧:

草稿图

这个图已经很明显了, 1和2 之间的距离就是半径, 所以我们只要求出点1 和点4 好像 就OK了, canvas 中是怎么画圆呢有一个arc 这个api :

arc(x, y, radius, startAngle, endAngle, anticlockwise)

画一个以(x,y)为圆心的以radius为半径的圆弧(圆),从startAngle开始到endAngle结束,按照anticlockwise给定的方向(默认为顺时针)来生成。

注意:arc()函数中表示角的单位是弧度,不是角度。角度与弧度的js表达式:

弧度=(Math.PI/180)*角度。

圆肯定就是从0-360度, 代码如下:

drawCircle(center, radius = 4) {
  const { x, y } = center
  this.ctx.beginPath()
  this.ctx.arc(x, y, radius, 0, Math.PI * 2, true) // 绘制
  this.ctx.fill()
}

准备工作都做好了, 我们就开始实现话带圆的直线吧。 画图的步骤就是

  1. 先画开始圆
  2. 画直线
  3. 画结束圆

画开始圆和画结束圆其实可以封装成一个方法: 他们最主要的区别其实就是起始点的不同,代码如下:

drawLineCircle(start, end, type) {
  const flag = type === 'left'
  const { x: startX, y: startY } = start
  const { x: endX, y: endY } = end
  const center = this.getOnePointOnLine(
    start.clone(),
    end.clone(),
    flag ? startX - this.distance : endX + this.distance
  )
  // 两点之间的距离  不熟悉的小伙伴可以看下上面的文章
  const radius = (flag ? start : end).clone().distanceSq(center)
  this.drawCircle(center, radius)
}

这样我们就可以画圆了。先看下效果图:

直线两端圆点

到这里我们就已经结束了折线图的第一个部分, 紧接着进入第二部分:

画XY坐标轴

​ 坐标轴本质上就是两条直线,所以第一步确定坐标原点,然后以坐标原点画出垂直和水平的两条直线。 我们设置坐标原点离画布的左内边距和底部内边距,这样我们可以通过画布的高度减去底部内边距得到 原点的y, 然后通过画布的宽度减去左内边距得到x, 有了坐标原点画坐标轴就没什么大问题了。代码如下:

  //定义坐标轴相对于画布的内边距
  this.paddingLeft = 30 // 至少大于绘制文字的宽度
  this.paddingBottom = 30 // 至少大于绘制文字的高度
  this.origin = new Point2d(
    this.paddingLeft,
    this.height - this.paddingBottom
  )
  this.drawCircle(this.origin, 1, 'red')
  this.addxAxis()
  this.addyAxis()

  // 画 x 轴
  addxAxis() {
    const end = this.origin
      .clone()
      .add(new Point2d(this.width - this.paddingLeft * 2, 0))
    this.drawLine(this.origin, end)
  }
  
  // 画y轴
  addyAxis() {
    const end = this.origin
      .clone()
      .sub(new Point2d(0, this.height - this.paddingBottom * 2))
    this.drawLine(this.origin, end)
  }

这里要特别提示的是 首先整个画布的 坐标轴 是在整个屏幕的左上方, 但是我们显示的坐标原点是在 左下方, 然后 画Y轴的时候是由原点向上减去, 是向量点的减法。

效果图如下 :

image-20210710165738306.png

但是和echarts 那个不太一样, 他的x轴是有线段的和文字的,接下来我们就开始改造 x轴。就是将X轴分几段嘛,

然后生成一个点的集合,这些点的y都是相同的, 然后 x是不相同的。代码如下:

 drawLineWithDiscrete(start, end, n = 5) {
    // 由于 x 轴上的 y 都是相同的
    const points = []
    const startX = start.x
    const endX = end.x
    points.push(start)
    const segmentValue = (endX - startX) / n
    for (let i = 1; i <= n - 1; i++) {
      points.push(new Point2d(startX + i * segmentValue, start.y))
    }
    points.push(end)

    // 生成线段
    points.forEach((point) => {
      this.drawLine(point, point.clone().add(new Point2d(0, 5)))
    })
  }

这里要注意的就是循环的个数,因为起始点和终止点是有的。 看下效果图:

初始坐标轴 这时候还差文字,canvas 绘制文字的api

在指定的(x,y)位置填充指定的文本,绘制的最大宽度是可选的.
ctx.fillText(text,x,y,[,maxwidth]

所以说白了还是去计算文字点的坐标,首先在项目初始化的定义X轴和Y轴的数据。代码如下:

 this.axisData = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
 this.yxisData = ['0', '50', '100', '150', '200', '250', '300']

文字我们统一放在线段的中点处其实只要计算每个分段数的长度然后在端点处+分段数长度的一半就可以得到。代码如下:

// 生成X轴文字的点
const segmentValue = (endX - startX) / n
for (let i = 0; i <= n - 1; i++) {
  const textpoint = new Point2d(
    startX + i * segmentValue + segmentValue / 2,
    start.y + 20
  )
  // 这里每个点的文字与X轴数据是互相呼应的
  textPoints.push({
    point: textpoint,
    text: this.axisData[i],
  })
}

// 生成文字
this.clearFillColor()
textPoints.forEach((info) => {
  const { text, point } = info
  this.ctx.fillText(text, point.x, point.y)
})

效果图如下:

x轴文字

但是看着图好像文字并没有处于居中的位置, 胖虎思考了🤔一下, 其实因为文字也有长度, 所以每一个文字的坐标要减去文字长度的一半值就对了。这时候this.ctx.fillText 的第三个参数就显得十分重要了, 限制文字的长度, 这样我们就可以处理了, 代码 重新修改下:

// 限制文字的长度
this.ctx.fillText(text, point.x, point.y, 20)

// 文字的每个点要减去长度的一半
const textpoint = new Point2d(
  startX + i * segmentValue + segmentValue / 2 - 10,
  start.y + 20
)

直接看效果图:

x轴

这下看一下就是完美。

X轴的处理好了,我们处理Y轴,Y轴其实相对比较简单就是每个数据对应的一条直线。

Y轴的话也是要计算每个线段的长度的值,然后画出直线, 这里要特别注意的是就是文字的放置, 在每个端点还要进行微调。使得文字和直线居中对齐。代码如下:

addyAxis() {
  const end = this.origin
    .clone()
    .sub(new Point2d(0, this.height - this.paddingBottom * 2))
  const points = []
  const length = this.origin.y - end.y
  const segmentValue = length / this.yxisData.length
  for (let i = 0; i < this.yxisData.length; i++) {
    const point = new Point2d(end.x, this.origin.y - i * segmentValue)
    points.push({
      point,
      text: this.yxisData[i],
    })
  }
  points.forEach((info) => {
    const { text, point } = info
    const end = point
      .clone()
      .add(new Point2d(this.width - this.paddingLeft * 2, 0))
    this.setStrokeColor('#E0E6F1')
    this.drawLine(point, end)
    this.clearStrokeColor()
    this.ctx.fillText(text, point.clone().x - 30, point.y + 4, 20)
  })
}

因为过程和X轴十分相似, 提醒一下描边 设置后,要将它恢复默认,不然会引用上一个颜色哦。

如图:

坐标轴

整个画布就差最后一步了, 生成折线图, 我们在上面已经封装了,带圆的直线, 所以只要找到所有的点去画折线图就好了。首先每个点的X坐标没什么问题对应的就是每个文字的中点, 主要是Y轴的坐标: 回忆一下之前我们是怎么去计算Y轴的坐标的是, 长度/ 除以分段数 去计算的。 这样就导致一个问题,出来的结果可能是一个小数,因为我们实际的数据 可能是223 这种这样导致画出来的图形点误差太大, 所以为了减少误差, 我换一个计算模式,就是进行等分,这样在区间里面的点都可以表达, 误差可以稍微小点, 其实在实际项目中, 容差问题是计算肯定存在的问题,js 本身就有0.1+0.2 这样的问题, 所以或者说在容差范围内我们可以认为这两个点是等价的 代码如下:

const length = this.origin.y - end.y
const division = length / 300
const point = new Point2d(end.x, this.origin.y - i * division * 50)

然后我这时候引入真实的数据:

this.realData = [150, 230, 224, 218, 135, 147, 260]
this.xPoints = []
this.yPoints = []

分别对应的是真实的数据, xPoints是什么文字的中点坐标代码如下:

// 生成文字
this.clearFillColor()
textPoints.forEach((info) => {
  const { text, point } = info
  this.xPoints.push(point.x)
  this.ctx.fillText(text, point.x, point.y, 20)
})

yPoints其实也就比较简单了, 真实数据 * 每一份的距离就好了。

const division = length / 300
for (let i = 0; i < this.yxisData.length; i++) {
  const point = new Point2d(end.x, this.origin.y - i * division * 50)
  // 在这里, 还是得注意坐标轴的位置 
  const realData = this.realData[i]
  this.yPoints.push(this.origin.y - realData * division)
  points.push({
    point,
    text: this.yxisData[i],
  })
}

数据准备好了,我们就开始调用方法去画折线图:

let start = new Point2d(this.xPoints[0], this.yPoints[0])
// 生成折线图
this.setStrokeColor('#5370C6')
this.xPoints.slice(1).forEach((x, index) => {
  const end = new Point2d(x, this.yPoints[index + 1])
  this.drawLineWithCircle(start, end)
  start = end
})

这段代码需要注意的是默认找一个开始点, 然后 不断地去更改开始点, 然后注意下标位置。

如图:

点重复

目前存在的问题:

  1. 存在的圆点重复
  2. 圆点的半径大小不一致,说明我们之前计算圆心到直线的距离 这样设为 半径是错误的, 因为每条的线的斜率是不一样的。所以算出来是有问题的。

到这里打大家可以这么去思考,为什么圆和直线要捆绑在一起? 单独画不就没有这样的问题了。说干就干,

let start = new Point2d(this.xPoints[0], this.yPoints[0])
this.drawCircle(start)
// 生成折线图
this.setStrokeColor('#5370C6')
this.xPoints.slice(1).forEach((x, index) => {
  const end = new Point2d(x, this.yPoints[index + 1])
  // 画圆
  this.drawCircle(end)
  // 画直线
  this.drawLine(start, end)
  start = end
})

这里注意会少一个开始圆,我们在开头的直接补上就好了, 圆的半径我都统一设置了。

如图:

最终折线图

至此到这里, 这折线图全部完成,为了做的更完美一点,我还是增加的提示和虚线。

显示tooltip

这里我看大多数图表都在鼠标移动的时候都会显示一个虚线和提示,不然我怎么清除的看数据对吧。 我们还是初始化一个div将它的样式设置为隐藏。

#tooltip {
  position: absolute;
  z-index: 2;
  background: white;
  padding: 10px;
  border-radius: 2px;
  visibility: hidden;
}

<div id="tooltip"></div>

为canvas 增加监听事件:

canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
// 这里取相对于画布原点的位置 offset 
onMouseMove(e) {
  const x = e.offsetX
  const y = e.offsetY
}

其实我们要做的事情非常简单首先我们就是去比较鼠标的点 和 实际的点在某个范围内我就显示,类似于吸附, 从用户的角度不可能完全移动到那里才显示。

代码如下:

onMouseMove(e) {
  const x = e.offsetX
  const find = this.xPoints.findIndex(
    (item) => Math.abs(x - item) <= this.tolerance
  )
  if (find > -1) {
    this.tooltip.textContent = `数据:${this.axisData[find]}_ ${this.yxisData[find]}`
    this.tooltip.style.visibility = 'visible'
    this.tooltip.style.left = e.clientX + 2 + 'px'
    this.tooltip.style.top = e.clientY + 2 + 'px'
  } else {
    this.tooltip.style.visibility = 'hidden'
  }
}

这里其实只要比较x的位置就好了,容差可以自定义设置。

画垂直的虚线

我看了很多图表他们都有垂直的虚线,这里就涉及到一个问题canvas 如何画虚线, 我在用canvas 实现矩形的移动(点、线、面)(1)这篇文章有介绍, 我就直接拿过来,不过多解释了,感兴趣的小伙伴可以看下这篇文章。 代码如下:

drawDashLine(start, end) {
  if (!start || !end) {
    return
  }
  this.ctx.setLineDash([5, 10])
  this.beginPath()
  this.moveTo(start.x, start.y)
  this.lineTo(end.x, end.y)
  ctx.stroke()
}

我们对onMouseMove 再一次进行改造:

onMouseMove(e) {
  const x = e.offsetX
  const find = this.xPoints.findIndex(
    (item) => Math.abs(x - item) <= this.tolerance
  )
  if (find > -1) {
    this.tooltip.textContent = `数据:${this.axisData[find]}_ ${this.yxisData[find]}`
    this.tooltip.style.visibility = 'visible'
    this.tooltip.style.left = e.clientX + 2 + 'px'
    this.tooltip.style.top = e.clientY + 2 + 'px'
    // 画虚线
    const start = new Point2d(this.xPoints[find], this.origin.y)
    const end = new Point2d(this.xPoints[find], 0)
    this.drawDashLine(start, end)
  } else {
    this.tooltip.style.visibility = 'hidden'
  }
}

增加了以下代码, 但是这样是有问题的,就是我们鼠标不停的移动, 所以上一次绘制的虚线不会取消。会出现下面这种情况:

虚线图

所以我做了一个数据清除同时清除画布上的东西重新画:

clearData() {
  this.ctx.clearRect(0, 0, 600, 600)
  this.xPoints = []
  this.yPoints = []
}

整体代码如下:

const start = new Point2d(this.xPoints[find], this.origin.y)
const end = new Point2d(this.xPoints[find], 0)
// 清除数据
this.clearData()
this.drawDashLine(start, end)
// 虚线样式也要每次清除 不然会影响下面的画的样式
this.ctx.setLineDash([])
this.addxAxis()
this.addyAxis()
this.setStrokeColor('#5370C6')
this.generateLineChart()

restore和save的妙用

再给出一个小技巧**, 其实canvas 中 画图如果某次的样只想在某一个绘制中起作用:有save 和 restore方法

使用 save() 方法保存当前的状态,使用 restore() 进行恢复成一开始的样子

所以我们可以重新改写下画虚线的方法,在一开始的时候svae 一下, 然后结束在 restore , 有点像栈的感觉,先进去,然后画结束,弹出来。 每一项都有自己的独特的画图状态,不影响其他项。

drawDashLine(start, end) {
    if (!start || !end) {
      return
    }
    this.ctx.save()
    this.ctx.setLineDash([5, 10])
    this.beginPath()
    this.moveTo(start.x, start.y)
    this.lineTo(end.x, end.y)
    this.stroke()
    this.ctx.restore()
  }

至此整个折线图我想给大家讲解的已经结束了,我们看下效果吧:

折线图最终结果.gif

最后

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

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

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

最后一句话: 大家和我一起做一个Api的创造者而不是调用者!

源码下载

本篇文章例子的所有代码都在我的github上,欢迎star☆😯!