使用canvas画折线图和曲线图
- 贝塞尔曲线如果想要在p0=》p2的过程中经过p1,那么需要计算出pc的值,在canvas之中作为控制点
二次贝塞尔曲线转换为三次
上面只是简单介绍,具体的参考文档在 这里
这里只把最终的结果贴出来
控制点A和B坐标计算公式
其中i是开始点,i+1是结束点,i-1是开始点的前一个点,i+2是结束点的后一个点,使用这个公式可以把中间部分的曲线画出来(不包含两头的那两段),其中的4可以取任意值(一般在3-10),两端的那一段因为一开始没有前一个点,所以这个i-1可以使用i这个点代替,后面那一段i+2可以使用i+1代替
下面是例子:使用react编写,可在自己的react项目里面跑一下看看:
底下的每一步都是在canvas里面单独画的,如果要性能优化可以处理一下,将相同的线条一次画完填色,不需要每次都重新开始begin
现在是点击canvas出现新的一幅图,如果想要动态变化,横坐标纵坐标慢慢移动变化,可以把canvas里面的变化点都存起来,在添加新坐标时记录出新的坐标在哪里,和保存的坐标进行对比,然后使用setInterval一步步的变化到新的坐标上实现(只是一种思路)
import React, { useEffect, useRef, useState } from 'react'
import ResizeObserver from 'resize-observer-polyfill'
const CanvasLine = () => {
const canvasRef = useRef<HTMLCanvasElement | null>()
const [dataArr, setDataArr] = useState<number[]>([])
const [modelData] = useState([20, 50, 70, 130, 60, 30, 90])
const week = ['一', '二', '三', '四', '五', '六', '日']
// 如果想做出动态数据展示,那么可以把所有的操作都保存下来,在最后统一渲染,在每一项需要移动的点(操作)上记录需要的操作,在渲染完成之后一步步的移动点,在到达最终位置,停止更新
// const [showPoint, setShowPoint] = useState<any[]>()
// 贝塞尔曲线
const bse = (a: any, b: any, c: any, d: any) => {
const canvasDom = canvasRef.current!
const ctx = canvasDom.getContext('2d')!
ctx.beginPath()
ctx.moveTo(b.x, b.y)
ctx.strokeStyle = 'red'
const scale = 0.25 //分别对于ab控制点的一个正数,可以分别自行调整
// 根据abcd四个点计算bc两个点的贝塞尔曲线
// 具体的计算文档https://wenku.baidu.com/view/c790f8d46bec0975f565e211.html
// 第一个控制点
const pointOne: any = {}
pointOne.x = b.x + (c.x - a.x) * scale
pointOne.y = b.y + (c.y - a.y) * scale
const pointTwo: any = {}
pointTwo.x = c.x - (d.x - b.x) * scale
pointTwo.y = c.y - (d.y - b.y) * scale
ctx.bezierCurveTo(pointOne.x, pointOne.y, pointTwo.x, pointTwo.y, c.x, c.y)
ctx.stroke()
ctx.closePath()
}
// 3.根据数据排序展示
// 横坐标使用week,纵坐标自己计算数据确定范围
// 监听元素宽度变化,根据宽度设置当前折线图的大小
// space距离边的间隔 x:x轴最右端 y:y轴的最低端 xy的最小都是space
const checkData = (space: number, x: number, y: number) => {
const dataLength = dataArr.length
const canvasDom = canvasRef.current!
const ctx = canvasDom.getContext('2d')!
// 获取数据最大值
const maxNum = Math.max(...dataArr)
// 获取数据最小值
const minNum = Math.min(...dataArr)
// 根据最大值最小值和space-x来设置
// 计算x轴的坐标数据书写位置
const xtemp = (x - space - 40) / dataLength
const ytemp = (y - space - 40) / dataLength
// 计算y轴坐标值,x轴的坐标值是直接取传进来的
const yvalue = (maxNum - minNum) / dataLength
// 开始渲染这些坐标x和获取x坐标
const arrx = []
for (let i = 0; i < dataLength; i++) {
ctx.beginPath()
ctx.font = '40px Microsoft YaHei'
//水平对齐方式
ctx.textAlign = 'center'
//垂直对齐方式
ctx.textBaseline = 'middle'
ctx.fillText(week[i], space + i * xtemp, y + space / 2)
ctx.stroke()
arrx.push(space + i * xtemp)
ctx.closePath()
}
// 开始渲染这些坐标y
for (let i = 1; i <= dataLength; i++) {
ctx.beginPath()
ctx.font = '40px Microsoft YaHei'
//水平对齐方式
ctx.textAlign = 'center'
//垂直对齐方式
ctx.textBaseline = 'middle'
ctx.fillText((minNum + i * yvalue).toFixed(0), space / 2, y - ytemp * i)
ctx.stroke()
ctx.closePath()
}
// 通过获取到的x坐标来绘制点线
for (let i = 0; i < dataLength; i++) {
if (i != 0 && i != dataLength - 1 && i != dataLength - 2) {
bse(
{ x: arrx[i - 1], y: y - (dataArr[i - 1] / maxNum) * (y - space - 40) },
{ x: arrx[i], y: y - (dataArr[i] / maxNum) * (y - space - 40) },
{ x: arrx[i + 1], y: y - (dataArr[i + 1] / maxNum) * (y - space - 40) },
{ x: arrx[i + 2], y: y - (dataArr[i + 2] / maxNum) * (y - space - 40) }
)
}
// 这里是做几个情况的判断,在实际中是可以合并的,这里分开写容易理解
if (i == 0 && dataLength > 2) {
bse(
{ x: arrx[i], y: y - (dataArr[i] / maxNum) * (y - space - 40) },
{ x: arrx[i], y: y - (dataArr[i] / maxNum) * (y - space - 40) },
{ x: arrx[i + 1], y: y - (dataArr[i + 1] / maxNum) * (y - space - 40) },
{ x: arrx[i + 2], y: y - (dataArr[i + 2] / maxNum) * (y - space - 40) }
)
}
if (i == dataLength - 2 && dataLength > 2) {
bse(
{ x: arrx[i - 1], y: y - (dataArr[i - 1] / maxNum) * (y - space - 40) },
{ x: arrx[i], y: y - (dataArr[i] / maxNum) * (y - space - 40) },
{ x: arrx[i + 1], y: y - (dataArr[i + 1] / maxNum) * (y - space - 40) },
{ x: arrx[i + 1], y: y - (dataArr[i + 1] / maxNum) * (y - space - 40) }
)
}
if (i == 1 && dataLength == 2) {
bse(
{ x: arrx[i - 1], y: y - (dataArr[i - 1] / maxNum) * (y - space - 40) },
{ x: arrx[i - 1], y: y - (dataArr[i - 1] / maxNum) * (y - space - 40) },
{ x: arrx[i], y: y - (dataArr[i] / maxNum) * (y - space - 40) },
{ x: arrx[i], y: y - (dataArr[i] / maxNum) * (y - space - 40) }
)
}
}
ctx.beginPath()
ctx.moveTo(space, y)
for (let i = 0; i < dataLength; i++) {
ctx.lineTo(arrx[i], y - (dataArr[i] / maxNum) * (y - space - 40))
ctx.lineWidth = 2
ctx.strokeStyle = '#000'
ctx.stroke()
ctx.strokeStyle = '#fff'
if (i == dataLength - 1) {
ctx.lineTo(arrx[i], y)
}
}
const linearGradient = ctx.createLinearGradient(0, 0, 0, y)
linearGradient.addColorStop(0, 'rgba(19,144,239,0.3)')
linearGradient.addColorStop(0.8, 'rgba(255,255,255,0.3)')
ctx.fillStyle = linearGradient
ctx.fill()
ctx.closePath()
}
// 绘画折线图
// 1.确定网格的大小,以决定可以画多少条x轴的线,多少y轴的线 画出网格
const setGrid = () => {
const canvasDom = canvasRef.current!
const ctx = canvasDom.getContext('2d')!
const gridSize = 50 //设置网格大小为10;
const canvasHeight = canvasDom.height
const canvasWidth = canvasDom.width
// 重置画布的背景色
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
const xLineTotal = Math.floor(canvasHeight / gridSize)
//画x轴条数
for (let i = 0; i <= xLineTotal; i++) {
ctx.beginPath()
ctx.moveTo(0, i * gridSize - 0.5)
ctx.lineTo(canvasWidth, i * gridSize - 0.5)
ctx.strokeStyle = '#eee'
ctx.stroke()
}
const yLineTotal = Math.floor(canvasWidth / gridSize)
//画y轴条数
for (let i = 0; i <= yLineTotal; i++) {
ctx.beginPath()
ctx.moveTo(i * gridSize - 0.5, 0)
ctx.lineTo(i * gridSize - 0.5, canvasHeight)
ctx.strokeStyle = '#eee'
ctx.stroke()
}
}
//2.绘制坐标系
const setCoordinate = () => {
//先确定原点:确定距离画布旁边的距离,通过距离算出原点
const canvasDom = canvasRef.current!
const ctx = canvasDom.getContext('2d')!
//计算原点
const canvasWidth = canvasDom.width
const canvasHeight = canvasDom.height
//确定坐标的长度:
//确定箭头的大小: 一半3,6,,直角边
const space = 100
const arrowSize = 12
const lineWidth = 2
const x0 = space
const y0 = canvasHeight - space
//画x轴
ctx.beginPath()
ctx.moveTo(x0, y0 - 0.5)
ctx.lineTo(canvasWidth - x0, y0 - 0.5)
ctx.strokeStyle = '#000'
ctx.lineWidth = lineWidth
ctx.stroke()
ctx.closePath()
//x轴画箭头
ctx.beginPath()
ctx.moveTo(canvasWidth - x0, y0)
ctx.lineTo(canvasWidth - x0 - arrowSize, y0 + arrowSize / 2)
ctx.lineTo(canvasWidth - x0 - arrowSize, y0 - arrowSize / 2)
ctx.lineTo(canvasWidth - x0, y0)
ctx.fillStyle = '#000'
ctx.fill()
//画y轴
ctx.beginPath()
ctx.moveTo(x0 - 0.5, y0)
ctx.lineTo(x0 - 0.5, space)
ctx.stroke()
ctx.closePath()
//画y轴箭头
ctx.beginPath()
ctx.moveTo(x0, space)
ctx.lineTo(x0 + arrowSize / 2, space + arrowSize)
ctx.lineTo(x0 - arrowSize / 2, space + arrowSize)
ctx.lineTo(x0, space)
ctx.fill()
ctx.closePath()
checkData(space, canvasWidth - x0, y0)
}
// 确定canvas大小
const getOnSize = () => {
const canvasDom = canvasRef.current!
// 这里确定canvas大小是通过父元素大小来确定的,因为我这个项目最外层的父元素是这个,所以才取得这个元素,在实际中可做修改,或者写成需要的大小,这里是为了在元素宽高发生变化的时候canvas跟着改变
const boxDom = canvasDom.closest('.ant-layout-content')!
let boxDomWidth: number
let boxDomHeight: number
const fun = (data: any) => {
if (canvasDom) {
boxDomWidth = data[0].contentRect.width
boxDomHeight = data[0].contentRect.height
// 获取到盒子的宽高之后就把画布的宽高设置成两倍
canvasDom.height = boxDomHeight * 1
canvasDom.width = boxDomWidth * 1
// canvas元素的样式设置成一倍
canvasDom.style.height = String(boxDomHeight + 'px')
canvasDom.style.width = String(boxDomWidth + 'px')
setGrid()
setCoordinate()
}
}
const observer = new ResizeObserver(fun)
// 监听父元素大小变化,父元素大小变化就重新画canvas
observer.observe(boxDom)
}
useEffect(() => {
getOnSize()
}, [dataArr])
const setDataFun = () => {
setDataArr((a) => {
if (dataArr.length == modelData.length) {
return a
}
return [...a, modelData[dataArr.length]]
})
}
return (
<canvas
onClick={() => {
setDataFun()
}}
ref={(a) => {
canvasRef.current = a
}}></canvas>
)
}
export default CanvasLine
结果:
值是[20,50,20,50,20,50,20]的图形