图表绑制

Canvas图表绑制完整指南,涵盖柱状图、折线图、饼图、雷达图等各类图表的实现方法。Canvas是绑制数据图表的理想选择,本节将介绍如何使用Canvas绑制各类常用图表。

图表基础

图表结构

一个完整的图表通常包含以下组成部分:

class Chart {
  constructor(canvas, options = {}) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    
    this.options = {
      padding: { top: 40, right: 40, bottom: 60, left: 60 },
      backgroundColor: '#fff',
      gridColor: '#ecf0f1',
      axisColor: '#bdc3c7',
      textColor: '#333',
      fontSize: 12,
      fontFamily: 'Arial',
      ...options
    }
    
    this.data = []
    this.animationProgress = 0
    this.isAnimating = false
  }
  
  setData(data) {
    this.data = data
    return this
  }
  
  getChartArea() {
    return {
      x: this.options.padding.left,
      y: this.options.padding.top,
      width: this.canvas.width - this.options.padding.left - this.options.padding.right,
      height: this.canvas.height - this.options.padding.top - this.options.padding.bottom
    }
  }
  
  clear() {
    this.ctx.fillStyle = this.options.backgroundColor
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
  }
  
  animate(duration = 1000) {
    this.animationProgress = 0
    this.isAnimating = true
    const startTime = performance.now()
    
    const step = (currentTime) => {
      const elapsed = currentTime - startTime
      this.animationProgress = Math.min(elapsed / duration, 1)
      
      this.render()
      
      if (this.animationProgress < 1) {
        requestAnimationFrame(step)
      } else {
        this.isAnimating = false
      }
    }
    
    requestAnimationFrame(step)
  }
  
  render() {
    this.clear()
    this.drawGrid()
    this.drawAxes()
    this.drawData()
    this.drawLabels()
  }
  
  drawGrid() {
    // 子类实现
  }
  
  drawAxes() {
    // 子类实现
  }
  
  drawData() {
    // 子类实现
  }
  
  drawLabels() {
    // 子类实现
  }
}

坐标轴绘制

class Axis {
  constructor(ctx, options = {}) {
    this.ctx = ctx
    this.options = {
      min: 0,
      max: 100,
      ticks: 5,
      tickSize: 5,
      color: '#bdc3c7',
      textColor: '#333',
      fontSize: 12,
      format: (value) => value.toString(),
      ...options
    }
  }
  
  getTickValues() {
    const values = []
    const step = (this.options.max - this.options.min) / (this.options.ticks - 1)
    
    for (let i = 0; i < this.options.ticks; i++) {
      values.push(this.options.min + step * i)
    }
    
    return values
  }
  
  drawXAxis(x, y, width) {
    const ctx = this.ctx
    
    ctx.strokeStyle = this.options.color
    ctx.lineWidth = 1
    ctx.beginPath()
    ctx.moveTo(x, y)
    ctx.lineTo(x + width, y)
    ctx.stroke()
    
    const tickValues = this.getTickValues()
    const tickSpacing = width / (tickValues.length - 1)
    
    ctx.fillStyle = this.options.textColor
    ctx.font = `${this.options.fontSize}px ${this.options.fontFamily || 'Arial'}`
    ctx.textAlign = 'center'
    ctx.textBaseline = 'top'
    
    tickValues.forEach((value, i) => {
      const tickX = x + tickSpacing * i
      
      ctx.beginPath()
      ctx.moveTo(tickX, y)
      ctx.lineTo(tickX, y + this.options.tickSize)
      ctx.stroke()
      
      ctx.fillText(this.options.format(value), tickX, y + this.options.tickSize + 5)
    })
  }
  
  drawYAxis(x, y, height) {
    const ctx = this.ctx
    
    ctx.strokeStyle = this.options.color
    ctx.lineWidth = 1
    ctx.beginPath()
    ctx.moveTo(x, y)
    ctx.lineTo(x, y + height)
    ctx.stroke()
    
    const tickValues = this.getTickValues()
    const tickSpacing = height / (tickValues.length - 1)
    
    ctx.fillStyle = this.options.textColor
    ctx.font = `${this.options.fontSize}px ${this.options.fontFamily || 'Arial'}`
    ctx.textAlign = 'right'
    ctx.textBaseline = 'middle'
    
    tickValues.forEach((value, i) => {
      const tickY = y + height - tickSpacing * i
      
      ctx.beginPath()
      ctx.moveTo(x - this.options.tickSize, tickY)
      ctx.lineTo(x, tickY)
      ctx.stroke()
      
      ctx.fillText(this.options.format(value), x - this.options.tickSize - 5, tickY)
    })
  }
}

柱状图

柱状图示例

柱状图实现

class BarChart extends Chart {
  constructor(canvas, options = {}) {
    super(canvas, {
      barWidth: 0.6,
      barGap: 0.4,
      cornerRadius: 0,
      showValues: true,
      ...options
    })
  }
  
  drawGrid() {
    const area = this.getChartArea()
    const ctx = this.ctx
    
    ctx.strokeStyle = this.options.gridColor
    ctx.lineWidth = 1
    
    const gridLines = 5
    for (let i = 0; i <= gridLines; i++) {
      const y = area.y + (area.height / gridLines) * i
      ctx.beginPath()
      ctx.moveTo(area.x, y)
      ctx.lineTo(area.x + area.width, y)
      ctx.stroke()
    }
  }
  
  drawAxes() {
    const area = this.getChartArea()
    const ctx = this.ctx
    
    ctx.strokeStyle = this.options.axisColor
    ctx.lineWidth = 2
    
    ctx.beginPath()
    ctx.moveTo(area.x, area.y)
    ctx.lineTo(area.x, area.y + area.height)
    ctx.lineTo(area.x + area.width, area.y + area.height)
    ctx.stroke()
    
    const maxValue = Math.max(...this.data.map(d => d.value))
    ctx.fillStyle = this.options.textColor
    ctx.font = `${this.options.fontSize}px ${this.options.fontFamily}`
    ctx.textAlign = 'right'
    ctx.textBaseline = 'middle'
    
    for (let i = 0; i <= 5; i++) {
      const value = Math.round(maxValue * (5 - i) / 5)
      const y = area.y + (area.height / 5) * i
      ctx.fillText(value.toString(), area.x - 10, y)
    }
  }
  
  drawData() {
    const area = this.getChartArea()
    const ctx = this.ctx
    const maxValue = Math.max(...this.data.map(d => d.value))
    
    const totalBarWidth = area.width / this.data.length
    const barWidth = totalBarWidth * this.options.barWidth
    const barGap = totalBarWidth * this.options.barGap
    
    this.data.forEach((item, i) => {
      const x = area.x + totalBarWidth * i + barGap / 2
      const barHeight = (item.value / maxValue) * area.height * this.animationProgress
      const y = area.y + area.height - barHeight
      
      ctx.fillStyle = item.color || this.options.colors[i % this.options.colors.length]
      
      if (this.options.cornerRadius > 0) {
        this.drawRoundedRect(x, y, barWidth, barHeight, this.options.cornerRadius)
      } else {
        ctx.fillRect(x, y, barWidth, barHeight)
      }
      
      if (this.options.showValues && this.animationProgress >= 1) {
        ctx.fillStyle = this.options.textColor
        ctx.font = `bold ${this.options.fontSize}px ${this.options.fontFamily}`
        ctx.textAlign = 'center'
        ctx.fillText(item.value.toString(), x + barWidth / 2, y - 8)
      }
    })
  }
  
  drawLabels() {
    const area = this.getChartArea()
    const ctx = this.ctx
    const totalBarWidth = area.width / this.data.length
    const barWidth = totalBarWidth * this.options.barWidth
    const barGap = totalBarWidth * this.options.barGap
    
    ctx.fillStyle = this.options.textColor
    ctx.font = `${this.options.fontSize}px ${this.options.fontFamily}`
    ctx.textAlign = 'center'
    ctx.textBaseline = 'top'
    
    this.data.forEach((item, i) => {
      const x = area.x + totalBarWidth * i + barGap / 2
      ctx.fillText(item.label, x + barWidth / 2, area.y + area.height + 10)
    })
  }
  
  drawRoundedRect(x, y, width, height, radius) {
    const ctx = this.ctx
    ctx.beginPath()
    ctx.moveTo(x + radius, y)
    ctx.lineTo(x + width - radius, y)
    ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
    ctx.lineTo(x + width, y + height)
    ctx.lineTo(x, y + height)
    ctx.lineTo(x, y + radius)
    ctx.quadraticCurveTo(x, y, x + radius, y)
    ctx.fill()
  }
}

折线图

折线图示例

折线图实现

class LineChart extends Chart {
  constructor(canvas, options = {}) {
    super(canvas, {
      lineWidth: 3,
      pointRadius: 6,
      showPoints: true,
      showArea: true,
      smooth: false,
      ...options
    })
  }
  
  drawData() {
    const area = this.getChartArea()
    const ctx = this.ctx
    const maxValue = Math.max(...this.data.map(d => d.value))
    const pointSpacing = area.width / (this.data.length - 1)
    
    const points = this.data.map((item, i) => ({
      x: area.x + pointSpacing * i,
      y: area.y + area.height - (item.value / maxValue) * area.height * this.animationProgress
    }))
    
    const visiblePoints = Math.ceil(points.length * this.animationProgress)
    
    if (this.options.showArea) {
      this.drawArea(points.slice(0, visiblePoints), area)
    }
    
    this.drawLine(points.slice(0, visiblePoints))
    
    if (this.options.showPoints) {
      this.drawPoints(points.slice(0, visiblePoints))
    }
  }
  
  drawArea(points, area) {
    const ctx = this.ctx
    const gradient = ctx.createLinearGradient(0, area.y, 0, area.y + area.height)
    gradient.addColorStop(0, 'rgba(52, 152, 219, 0.3)')
    gradient.addColorStop(1, 'rgba(52, 152, 219, 0)')
    
    ctx.beginPath()
    ctx.moveTo(points[0].x, area.y + area.height)
    
    if (this.options.smooth) {
      this.drawSmoothPath(points)
    } else {
      points.forEach(p => ctx.lineTo(p.x, p.y))
    }
    
    ctx.lineTo(points[points.length - 1].x, area.y + area.height)
    ctx.closePath()
    ctx.fillStyle = gradient
    ctx.fill()
  }
  
  drawLine(points) {
    const ctx = this.ctx
    
    ctx.strokeStyle = this.options.colors?.[0] || '#3498db'
    ctx.lineWidth = this.options.lineWidth
    ctx.lineCap = 'round'
    ctx.lineJoin = 'round'
    
    ctx.beginPath()
    
    if (this.options.smooth) {
      this.drawSmoothPath(points)
    } else {
      points.forEach((p, i) => {
        if (i === 0) ctx.moveTo(p.x, p.y)
        else ctx.lineTo(p.x, p.y)
      })
    }
    
    ctx.stroke()
  }
  
  drawSmoothPath(points) {
    const ctx = this.ctx
    
    ctx.moveTo(points[0].x, points[0].y)
    
    for (let i = 1; i < points.length - 1; i++) {
      const xc = (points[i].x + points[i + 1].x) / 2
      const yc = (points[i].y + points[i + 1].y) / 2
      ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc)
    }
    
    if (points.length > 1) {
      const last = points[points.length - 1]
      ctx.lineTo(last.x, last.y)
    }
  }
  
  drawPoints(points) {
    const ctx = this.ctx
    const color = this.options.colors?.[0] || '#3498db'
    
    points.forEach(p => {
      ctx.fillStyle = '#fff'
      ctx.beginPath()
      ctx.arc(p.x, p.y, this.options.pointRadius, 0, Math.PI * 2)
      ctx.fill()
      
      ctx.strokeStyle = color
      ctx.lineWidth = 3
      ctx.stroke()
    })
  }
}

饼图

饼图示例

饼图实现

class PieChart extends Chart {
  constructor(canvas, options = {}) {
    super(canvas, {
      innerRadius: 0,
      showLabels: true,
      showLegend: true,
      legendPosition: 'right',
      ...options
    })
  }
  
  drawData() {
    const ctx = this.ctx
    const centerX = this.canvas.width / 2 - 50
    const centerY = this.canvas.height / 2
    const radius = Math.min(centerX, centerY) - this.options.padding.top
    const innerRadius = radius * this.options.innerRadius
    
    const total = this.data.reduce((sum, d) => sum + d.value, 0)
    let currentAngle = -Math.PI / 2
    
    this.data.forEach((item, i) => {
      const sliceAngle = (item.value / total) * Math.PI * 2 * this.animationProgress
      
      ctx.beginPath()
      ctx.moveTo(
        centerX + innerRadius * Math.cos(currentAngle),
        centerY + innerRadius * Math.sin(currentAngle)
      )
      ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle)
      ctx.arc(centerX, centerY, innerRadius, currentAngle + sliceAngle, currentAngle, true)
      ctx.closePath()
      
      ctx.fillStyle = item.color || this.options.colors[i % this.options.colors.length]
      ctx.fill()
      
      if (this.options.showLabels && this.animationProgress >= 1) {
        const midAngle = currentAngle + sliceAngle / 2
        const labelRadius = radius + 20
        const labelX = centerX + labelRadius * Math.cos(midAngle)
        const labelY = centerY + labelRadius * Math.sin(midAngle)
        
        ctx.fillStyle = this.options.textColor
        ctx.font = `${this.options.fontSize}px ${this.options.fontFamily}`
        ctx.textAlign = midAngle > Math.PI / 2 && midAngle < Math.PI * 1.5 ? 'right' : 'left'
        ctx.textBaseline = 'middle'
        ctx.fillText(`${item.label} (${Math.round(item.value / total * 100)}%)`, labelX, labelY)
      }
      
      currentAngle += sliceAngle
    })
  }
  
  drawLegend() {
    if (!this.options.showLegend) return
    
    const ctx = this.ctx
    const legendX = this.canvas.width - 120
    let legendY = this.options.padding.top
    
    this.data.forEach((item, i) => {
      ctx.fillStyle = item.color || this.options.colors[i % this.options.colors.length]
      ctx.fillRect(legendX, legendY, 15, 15)
      
      ctx.fillStyle = this.options.textColor
      ctx.font = `${this.options.fontSize}px ${this.options.fontFamily}`
      ctx.textAlign = 'left'
      ctx.textBaseline = 'middle'
      ctx.fillText(item.label, legendX + 22, legendY + 7)
      
      legendY += 25
    })
  }
}

雷达图

雷达图示例

雷达图实现

class RadarChart extends Chart {
  constructor(canvas, options = {}) {
    super(canvas, {
      levels: 5,
      maxValue: 100,
      showLabels: true,
      ...options
    })
  }
  
  drawGrid() {
    const ctx = this.ctx
    const centerX = this.canvas.width / 2
    const centerY = this.canvas.height / 2
    const radius = Math.min(centerX, centerY) - this.options.padding.top
    const labels = this.options.labels || []
    const angleStep = (Math.PI * 2) / labels.length
    
    for (let level = 1; level <= this.options.levels; level++) {
      const levelRadius = (radius / this.options.levels) * level
      
      ctx.strokeStyle = level === this.options.levels ? this.options.axisColor : this.options.gridColor
      ctx.lineWidth = level === this.options.levels ? 2 : 1
      ctx.beginPath()
      
      for (let i = 0; i <= labels.length; i++) {
        const angle = angleStep * i - Math.PI / 2
        const x = centerX + levelRadius * Math.cos(angle)
        const y = centerY + levelRadius * Math.sin(angle)
        
        if (i === 0) ctx.moveTo(x, y)
        else ctx.lineTo(x, y)
      }
      
      ctx.stroke()
    }
    
    ctx.strokeStyle = this.options.gridColor
    ctx.lineWidth = 1
    
    for (let i = 0; i < labels.length; i++) {
      const angle = angleStep * i - Math.PI / 2
      ctx.beginPath()
      ctx.moveTo(centerX, centerY)
      ctx.lineTo(
        centerX + radius * Math.cos(angle),
        centerY + radius * Math.sin(angle)
      )
      ctx.stroke()
    }
  }
  
  drawData() {
    const ctx = this.ctx
    const centerX = this.canvas.width / 2
    const centerY = this.canvas.height / 2
    const radius = Math.min(centerX, centerY) - this.options.padding.top
    const labels = this.options.labels || []
    const angleStep = (Math.PI * 2) / labels.length
    
    this.data.forEach((series, seriesIndex) => {
      const color = series.color || this.options.colors[seriesIndex % this.options.colors.length]
      
      ctx.beginPath()
      
      series.values.forEach((value, i) => {
        const angle = angleStep * i - Math.PI / 2
        const valueRadius = (value / this.options.maxValue) * radius * this.animationProgress
        const x = centerX + valueRadius * Math.cos(angle)
        const y = centerY + valueRadius * Math.sin(angle)
        
        if (i === 0) ctx.moveTo(x, y)
        else ctx.lineTo(x, y)
      })
      
      ctx.closePath()
      ctx.fillStyle = color + '40'
      ctx.fill()
      ctx.strokeStyle = color
      ctx.lineWidth = 2
      ctx.stroke()
      
      series.values.forEach((value, i) => {
        const angle = angleStep * i - Math.PI / 2
        const valueRadius = (value / this.options.maxValue) * radius * this.animationProgress
        const x = centerX + valueRadius * Math.cos(angle)
        const y = centerY + valueRadius * Math.sin(angle)
        
        ctx.fillStyle = '#fff'
        ctx.beginPath()
        ctx.arc(x, y, 4, 0, Math.PI * 2)
        ctx.fill()
        ctx.strokeStyle = color
        ctx.lineWidth = 2
        ctx.stroke()
      })
    })
  }
}

图表交互

鼠标悬停提示

class ChartTooltip {
  constructor(chart) {
    this.chart = chart
    this.visible = false
    this.x = 0
    this.y = 0
    this.content = ''
    
    this.chart.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
    this.chart.canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this))
  }
  
  onMouseMove(e) {
    const rect = this.chart.canvas.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    
    const dataPoint = this.chart.getDataAtPoint(x, y)
    
    if (dataPoint) {
      this.visible = true
      this.x = x
      this.y = y
      this.content = this.formatContent(dataPoint)
      this.chart.render()
      this.render()
    } else {
      this.hide()
    }
  }
  
  onMouseLeave() {
    this.hide()
  }
  
  hide() {
    if (this.visible) {
      this.visible = false
      this.chart.render()
    }
  }
  
  formatContent(dataPoint) {
    return `${dataPoint.label}: ${dataPoint.value}`
  }
  
  render() {
    if (!this.visible) return
    
    const ctx = this.chart.ctx
    const padding = 10
    
    ctx.font = '12px Arial'
    const textWidth = ctx.measureText(this.content).width
    
    let tooltipX = this.x + 15
    let tooltipY = this.y - 10
    
    if (tooltipX + textWidth + padding * 2 > this.chart.canvas.width) {
      tooltipX = this.x - textWidth - padding * 2 - 15
    }
    
    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
    ctx.beginPath()
    ctx.roundRect(tooltipX, tooltipY - 20, textWidth + padding * 2, 30, 5)
    ctx.fill()
    
    ctx.fillStyle = '#fff'
    ctx.textAlign = 'left'
    ctx.textBaseline = 'middle'
    ctx.fillText(this.content, tooltipX + padding, tooltipY - 5)
  }
}

动画效果

const Easing = {
  linear: t => t,
  easeInQuad: t => t * t,
  easeOutQuad: t => t * (2 - t),
  easeInOutQuad: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
  easeInCubic: t => t * t * t,
  easeOutCubic: t => (--t) * t * t + 1,
  easeInOutCubic: t => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
  easeInElastic: t => {
    if (t === 0 || t === 1) return t
    return -Math.pow(2, 10 * (t - 1)) * Math.sin((t - 1.1) * 5 * Math.PI)
  },
  easeOutElastic: t => {
    if (t === 0 || t === 1) return t
    return Math.pow(2, -10 * t) * Math.sin((t - 0.1) * 5 * Math.PI) + 1
  },
  easeOutBounce: t => {
    if (t < 1 / 2.75) {
      return 7.5625 * t * t
    } else if (t < 2 / 2.75) {
      return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75
    } else if (t < 2.5 / 2.75) {
      return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375
    } else {
      return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375
    }
  }
}

class AnimatedChart extends Chart {
  animate(easing = 'easeOutQuad', duration = 1000) {
    const easingFn = Easing[easing] || Easing.linear
    const startTime = performance.now()
    
    const step = (currentTime) => {
      const elapsed = currentTime - startTime
      const progress = Math.min(elapsed / duration, 1)
      
      this.animationProgress = easingFn(progress)
      this.render()
      
      if (progress < 1) {
        requestAnimationFrame(step)
      }
    }
    
    requestAnimationFrame(step)
  }
}