关于前端:建议收藏数据可视化带你从01实现折线图的多种方式

35次阅读

共计 12150 个字符,预计需要花费 31 分钟才能阅读完成。

前言

终于又到周末了,这次给大家分享一下可视化图表比较简单的图表📈但同时咱们又不得不学会的 那就是————折线图。读完本篇文章你能够学到什么

  1. js 实现直线方程
  2. 折线图的表白
  3. canvas 的一些小技巧

直线折线图

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

从图中能够失去以下 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 轴的时候是由原点向上减去,是向量点的减法。

效果图如下:

然而和 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)
})

效果图如下:

然而看着图如同文字并没有处于居中的地位,胖虎思考了🤔一下,其实因为文字也有长度,所以每一个文字的坐标要减去文字长度的一半值就对了。这时候 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 轴的解决好了,咱们解决 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()}

至此整个折线图我想给大家解说的曾经完结了,咱们看下成果吧:

最初

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

己在写文章的同时也在一直地思考,怎么去表白的更好。如果你对可视化感兴趣,点赞珍藏关注👍吧!,能够关注我下

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

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

源码下载

本篇文章例子的所有代码都在我的 github 上,欢送 star☆😯!如果你对图形感兴趣,能够关注我的公众号【前端图形】,支付可视化学习材料哦!!咱们下期再见👋

正文完
 0