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