canvas之旅系列----(二)

1,175 阅读5分钟

canvas绘制折线图

使用canvasApi来绘制一个柱状图,其效果如下。gitee源码地址github源码地址

使用canvasApi来绘制一个柱状图

其大致思路如上一篇文章一样。需要的步骤主要也是分为

  • 坐标轴绘制
    • 绘制坐标轴
    • 绘制刻度
  • 折线绘制

在上一篇的文章基础上,为了更加优雅的代码复用,所以采用对象的方式来管理相关配置和方法。

基础配置

/**
 * 初始化图形
 * @param {string} id canvas的id 
 */
function initChart(id) {
  const canvas = document.getElementById(id)
  const context = canvas.getContext('2d')
  // 用一个颜色做底色
  context.fillStyle = '#fafafa'
  context.fillRect(0, 0, canvas.width, canvas.height)
  context.fillStyle = '#000000'
  // 赋值属性
  this.canvas = canvas
  this.context = context
  this.chartZone = [50, 50, 700, 450]
  this.xAxisLable = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
  this.yAxisLable = ['0', '100', '200', '300', '400']
  this.yMax = 400
  this.data = [60, 150, 240, 230, 390, 310, 80]
  this._xLength = (this.chartZone[2] - this.chartZone[0]) * 0.98 
  this._yLength = (this.chartZone[3] - this.chartZone[1]) * 0.98 
  this._xLablePadding = 20
  this.points = null
} 

同上一篇文章一样,为了方便后续的工作,我们需要准备相应的配置项。但此次采用了对象的方式来管理。

坐标轴绘制

/**
 * 绘制x轴
 */
initChart.prototype.drawXAxis = function() {
  let gap = this._xLength / this.xAxisLable.length
  let self = this
  //绘制横线
  this.context.moveTo(this.chartZone[0], this.chartZone[3])
  this.context.lineTo(this.chartZone[2], this.chartZone[3])
  this.context.strokeStyle = '#353535'
  this.context.strokeWidth = 4
  this.context.stroke()
  //绘制刻度
  this.xAxisLable.forEach(function (lable, index) {
    self.context.font = '16px'
    self.context.textAlign = 'center'
    self.context.fillText(lable, self.chartZone[0] + (index + 0.5) * gap, self.chartZone[3] + self._xLablePadding, gap)
    self.context.moveTo(self.chartZone[0] + (index + 0.5) * gap, self.chartZone[3] + 10)
    self.context.lineTo(self.chartZone[0] + (index + 0.5) * gap, self.chartZone[3])
    self.context.stroke()
  })
  return this
}

坐标轴的绘制和上一篇文章一样,没有什么大的变化。此处列出绘制x轴的源码,y轴类似。详细源码可见仓库中源码。

绘制折线图

/**
 * 绘制折线图
 */
initChart.prototype.drawLine = function() {
  let self = this
  let gap = this._xLength / this.xAxisLable.length
  this.data.forEach(function(item, index) {
    let y = self.chartZone[3] - item * self._yLength / self.yMax
    let x = self.chartZone[0] + (index + 0.5) * gap
    if (index !== 0) {
      // 如果不是第一个点,则从上一个点绘制到当前点
      self.context.lineTo(x, y)
      self.context.strokeStyle = '#1abc9c'
      self.context.strokeWidth = 4
      self.context.stroke()
    }
    // 绘制一个纵向的辅助线
    self.context.beginPath()
    self.context.moveTo(x, y)
    self.context.lineTo(x, self.chartZone[3])
    self.context.setLineDash([8, 8])
    self.context.strokeStyle = '#aeaeae'
    self.context.strokeWidth = 2
    self.context.stroke()
    self.context.setLineDash([])
    // 绘制一个圆点来标记数据点
    self.context.beginPath()
    self.context.arc(x, y, 5, 0, 2 * Math.PI, false)
    self.context.fillStyle = '#1abc9c'
    self.context.fill()
    // 开始新的路径绘制,将画笔移动到当前点,为绘制到下一个点做准备
    self.context.beginPath()
    self.context.moveTo(x, y)
  })
  return this
}

折线图的绘制中,为了绘制辅助的线和点,我们需要注意绘制顺序,在每次beginPath的时候,相当于重新开始一条线的绘制。当然,也可以使用两次遍历数据源来绘制,逻辑结构也相对比较清晰。(在下面的示例中会见到这种做法)

为了让调用更加方便,我们可以加入一个方法统一来调用

/**
 * 绘制折线图调用
 */
initChart.prototype.drawLineChart = function() {
  this.drawXAxis().drawYAxis().drawLine()
}

曲线绘制

在上示例中,我们绘制出来的是一个折线图,如果我们想让线条更加流畅,绘制一条曲线图而非折线图,如下图所示,那么我们该如何做呢?

canvas绘制曲线图

三次贝塞尔曲线

如想绘制出上图所示的一条曲线,我们则需要使用到CanvasRenderingContext2D.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)方法来绘制三次贝赛尔曲线。详细参数解析可见MDN文档

三次贝赛尔曲线是由4个点来确定的,如何通过点来确定贝塞尔曲线的控制点位置,可参考文章《贝塞尔曲线控制点确定的方法》

/**
 * 将数据转化为坐标点
 */
initChart.prototype._getPoints = function() {
  if (this.points !== null)
    return this.points
  let points = []
  let gap = this._xLength / this.xAxisLable.length
  let self = this
  this.data.forEach(function(item, index) {
    let y = self.chartZone[3] - self._yLength * item / self.yMax
    let x = self.chartZone[0] + (index + 0.5) * gap
    points.push({x,y})
  })
  this.points = points
  return this.points
}
/**
 * 获取3次贝塞尔曲线的点
 */
initChart.prototype._getBezierPoints = function() {
  let _points = this._getPoints().slice()
  //左右填充一个节点
  let points = [_points[0], ..._points, _points[_points.length - 1]]
  //格式化贝塞尔曲线的点
  let bezierPoints = []
  for (let i = 2; i < points.length - 1; i++) {
    bezierPoints.push({
      dx: points[i].x,
      dy: points[i].y,
      cp1x: points[i-1].x + (points[i].x - points[i-2].x)/6,
      cp1y: points[i-1].y + (points[i].y - points[i-2].y)/6,
      cp2x: points[i].x - (points[i+1].x - points[i-1].x)/6,
      cp2y: points[i].y - (points[i+1].y - points[i-1].y)/6,
    })
  }
  return bezierPoints
}

本示例中的贝赛尔曲线的控制点确定如上所示。

绘制曲线

/**
 * 绘制一条平滑的曲线
 */
initChart.prototype.drawBezier = function() {
  let bezierPoints = this._getBezierPoints()
  let points = this._getPoints()
  let self = this
  let gap = this._xLength / this.xAxisLable.length
  // 绘制贝塞尔曲线
  this.context.beginPath()
  this.context.moveTo(points[0].x, points[0].y)
  bezierPoints.forEach(function(item) {
    self.context.bezierCurveTo(item.cp1x, item.cp1y, item.cp2x, item.cp2y, item.dx, item.dy)
  })
  self.context.strokeStyle = '#1abc9c'
  self.context.strokeWidth = 4
  self.context.stroke()
  // 绘制辅助点和线
  this.data.forEach(function(item, index) {
    let y = self.chartZone[3] - item * self._yLength / self.yMax
    let x = self.chartZone[0] + (index + 0.5) * gap
    // 绘制一个纵向的辅助线
    self.context.beginPath()
    self.context.moveTo(x, y)
    self.context.lineTo(x, self.chartZone[3])
    self.context.setLineDash([8, 8])
    self.context.strokeStyle = '#aeaeae'
    self.context.strokeWidth = 2
    self.context.stroke()
    self.context.setLineDash([])
    // 绘制一个圆点来标记数据点
    self.context.beginPath()
    self.context.arc(x, y, 5, 0, 2 * Math.PI, false)
    self.context.fillStyle = '#1abc9c'
    self.context.fill()
  })
  return this
}

绘制曲线的代码如上所示,此处使用了两次遍历来绘制,这样来写,对比折线的绘制的一次遍历方式,逻辑结构上更加清晰。

总结

在绘制折线图的时候碰到了一些问题,比如在绘制线条的时候,由于可能开始绘制其他部分修改了画笔颜色,未修改回来会导致颜色不对。为了避免出现这种问题,可以在每次绘制前都设置画笔颜色等属性,以确保不会出错。

扩展思考

除开上述的折线和曲线图以外,还有使区域带有颜色,如Echarts中的图

echarts

这种图又该如何实现这种图形的绘制呢?其实如上的方式也可以很方便来绘制迟来,除了绘制折线以外,我们还需要重新绘制一次折线,再将其和x轴围成一个封闭区域,然后对该区域填充一个颜色即可。此时会使用到closePathfill这两个方法,有兴趣的可以去尝试下。

在向下扩展,如果有多条线和多个堆叠的区域又该如何去绘制呢?