canvas封装折线图以及动画实现

canvas封装折线图

最终实现

<LineCharts
	data={data}
	lineColor='#333'
	labelColor='#ccc'

实现过程

import React from 'react';

export default class Linecharts extends React.Component {

  constructor(props){
    super(props);
  }

  componentDidMount(){
    this.renderChart()
  }

  componentWillReceiveProps(){ // 数据更新时需要同步重新渲染canvas
      this.renderChart();
  }

  renderChart(){
    const { data} = this.props;
    const canvas = document.getElementById('chart');
    const ctx = canvas.getContext('2d');
    let width = canvas.width; 
    let height = canvas.height;
    ctx.clearRect(0,0,width,height);  // 清除上一次绘制的画面
    let padding = 50; // 两边留白,用于渲染标签
    this.renderXy(ctx,padding,width, height); // 绘制x轴和y轴

    const yNumber = this.getMaxYNumber() ;// 获取数据中最大值
    let yLength = Math.floor((height - padding * 2) / yNumber);     // y轴每单位的长度
    let xLength = Math.floor((width - padding * 2) / data.length); // x轴每个单位的长度
    this.renderScale(ctx,yNumber,yLength,xLength,padding,height); // 渲染刻度线
  }

  getMaxYNumber(){
      const { data } =this.props;
      let max = 0;
      data.map(a=>{
        if(a.value > max){
            max = a.value;
        }
      })
      return max;
  }

  renderXy(ctx,padding,width, height){
    ctx.strokeStyle = this.props.scaleColor || '#000000'; // 设置轴线颜色
    ctx.beginPath();
    ctx.lineWidth = 1;
    // y轴线
    ctx.moveTo(padding + 0.5, height - padding + 0.5); // 移动到原点位置
    ctx.lineTo(padding + 0.5, padding + 0.5); // 移动到y轴顶部
    ctx.stroke();
    // x轴线
    ctx.moveTo(padding + 0.5, height - padding + 0.5); // 移动到原点
    ctx.lineTo(width - padding + 0.5, height - padding + 0.5); // 移动到右下角位置
    ctx.stroke();
  }

  renderScale(ctx,yNumber,yLength,xLength,padding,height){
    const {data } = this.props;
    ctx.beginPath();
    ctx.textAlign = 'center';
    ctx.fillStyle = this.props.labelColor|| '#000000'; // 设置标签颜色
    
    // x轴刻度和值
    data.map((item,k)=>{
      ctx.strokeStyle =  this.props.scaleColor ||'#000000';
      let value = item.label;
      let xlen = xLength*(k+ 1);
      ctx.moveTo(padding + xlen, height - padding);
      ctx.lineTo(padding + xlen, height - padding + 5);
      ctx.stroke();                                       // 画轴线上的刻度
      ctx.fillText(value, padding + xlen, height - padding + 15);   // 填充文字
    })
    // y轴刻度和值
    for (var i = 0; i < yNumber; i) {
        ctx.strokeStyle =  this.props.scaleColor ||'#000000';
        var ylen = yLength * (i + 2); // 这里以两个单位画一次刻度线,2需要通过计算y轴最大值来进行合理更改
        ctx.moveTo(padding, height - padding - ylen);
        ctx.lineTo(padding - 5, height - padding - ylen);
        ctx.stroke();
        ctx.fillText(i+2, padding - 20, height - padding - ylen + 5);
        i = i+2
    }
    this.renderData(ctx,padding,xLength,height,yLength); // 绘制折线
  }

  renderData(ctx,padding,xLength,height,yLength){
    const {data} = this.props;
    data.map((a,b)=>{
      ctx.strokeStyle =  this.props.lineColor||'#000000';
      if(b == 0){
          ctx.moveTo(padding + xLength,height - padding - a.value*yLength); 
          ctx.fillText(a.value, padding + xLength, height - padding - a.value*yLength - 10);
      }else {
          ctx.lineTo(padding + xLength * (b+1),height - padding - a.value*yLength);
          ctx.fillText(a.value, padding + xLength * (b+1), height - padding - a.value*yLength - 10);
      }
    })
    ctx.stroke();
  }
  render(){
    return (
      <div className='chats'>
          <div style={{textAlign:'center'}}>
            {this.props.title || ''}
          </div>
          <canvas width="600" height="500" id="chart" 
            onClick={(e)=>{
                console.log(e.clientX,e.pageX)
            }}
            />
      </div>   
    );
  }
}

多条折线图绘制

//处理数据,获取y轴最大值以及x轴最大个数,有需要标签也需要统计
getData(){
    const {data} = this.props;
    const classAry = [];
    const chartData = {};
    let maxLength = 0;
    let maxValue = 0;
    data.map(a=>{
        if( classAry.indexOf(a.class) === -1){
            classAry.push(a.class);
            chartData[a.class] = [a]
        }else {
            console.log(chartData)
            chartData[a.class].push(a);
        }
        if (a.value > maxValue){
            maxValue = a.value;
        }
    })
    Object.values(chartData).map(a=>{
        if (a.length> maxLength){
            maxLength = a.length
        }
    })
    this.setState({
        classAry,
        chartData,
        maxLength,
        maxValue
    },()=>{
        this.renderChart()
    })
  }
  //循环绘制多条折线
  Object.values(chartData).map(d=>{
        d.map((a,b)=>{
            ctx.strokeStyle =  this.props.lineColor||'#000000';
            if(b == 0){
                ctx.moveTo(padding + xLength,height - padding - a.value*yLength);
                ctx.fillText(a.value, padding + xLength, height - padding - a.value*yLength - 10);
            }else {
                ctx.lineTo(padding + xLength * (b+1),height - padding - a.value*yLength);
                ctx.fillText(a.value, padding + xLength * (b+1), height - padding - a.value*yLength - 10);
            }
          })
          ctx.stroke();
    })

折线图例以及提示文案

在这里插入图片描述
考虑方案:

  • canvas绑定事件

1、为了判断当前事件发生的位置所属的数据范围,在绘制x轴的时候存储下x轴范围,具体如下:

this.labels = {}; // 通过labels来存储位置
    // x轴刻度和值 
Object.values(chartData) && Object.values(chartData)[0].data.map((item,k)=>{ // 当前情况未考虑多个数据标签不一致情况。
        ctx.strokeStyle =  this.props.scaleColor ||'#000000';
        let value = item.label;
        let xlen = xLength*(k+ 1);
        ctx.moveTo(padding + xlen, height - padding);
        ctx.lineTo(padding + xlen, height - padding + 5);
        ctx.stroke();                                       // 画轴线上的刻度
        ctx.fillText(value, padding + xlen, height - padding + 15);   // 填充文字
        this.labels[value] = [padding + xlen -xLength/2 ,padding + xlen + xLength/2  ]; //
})

2、给canvas绑定事件

onMouseMove={(e)=>{
                const canvas = document.getElementById('chart');
                const ctx = canvas.getContext('2d');
               
                let x=e.clientX-canvas.offsetLeft; // 获取鼠标相对于canvas的位置x,y
                let y=e.clientY-canvas.offsetTop;
                let currentXlabel = ''; // 保存当前所处位置应当显示的x轴标签
                let currentDatas = []; // 保存当前位置应展示的不同类别对应的数据内容
                Object.keys(this.labels).map((key,i)=>{
                    if ((this.labels[key][0] && x> this.labels[key][0]) && (this.labels[key][1] && x <= this.labels[key][1]) && y <500 - 50 ){
                    // 判断 是否在对应x标签范围内,并且不要超出y轴 
                        currentXlabel = key;
                        Object.values(chartData).map((data)=>{
                            data.data.map(a=>{
                                if(a.label === key){
                                    currentDatas.push (a);
                                }
                            })
                        })
                    }
                })
                let legendHeight = 40 + currentDatas.length *20; // 提示框标题最小高度
                // 绘制提示框
                if( currentDatas.length> 0){
                    this.renderChart(); // 发生移动时重新绘制折线图,防止绘制多个提示框
                    ctx.fillStyle = 'rgba(0,0,0,0.5)';
                    ctx.fillRect(x,y,100,legendHeight);
                    ctx.fillStyle = 'rgba(255,255,255)';
                    ctx.fillText(currentXlabel, x+ 20, y+20); 
                    currentDatas.map((item, index)=>{
                        ctx.fillText(`${item.class}:${item.value}`, x+20, y+20 + 20*(index + 1)); 
                    })
                }else {
                    this.renderChart();
                }
            }} 

3、由于绘制折线图是随机颜色,鼠标移动重复绘制,导致折线图颜色不一致

// this.colors = ['#FFB6C1','#9370DB','#483D8B','#4169E1','#1E90FF','#ADD8E6','#BDB76B','#DAA520']
renderData(ctx,padding,xLength,height,yLength){
    const {chartData, isFirst} = this.state;
    
    Object.keys(chartData).map((key)=>{
        chartData[key].data.map((a,b)=>{
            let randomColor = '#000';
            if (isFirst){ // 第一次渲染或者数据源更改时 设置随机颜色
                randomColor = this.colors[Math.round(Math.random()*8)];
                chartData[key].lineColor =randomColor; // 存储后用于折线颜色以及图例颜色渲染
            }else {
                randomColor = chartData[key].lineColor;
            }
            ctx.strokeStyle = randomColor;
           
            if(b == 0){
                ctx.moveTo(padding + xLength,height - padding - a.value*yLength);
                ctx.fillText(a.value, padding + xLength, height - padding - a.value*yLength - 10);
                
            }else {
                ctx.lineTo(padding + xLength * (b+1),height - padding - a.value*yLength);
                ctx.fillText(a.value, padding + xLength * (b+1), height - padding - a.value*yLength - 10);
            }
          })
          ctx.stroke();
    })
    this.setState({
        chartData,
        isFirst: false
    })    
  }

4、渲染图例

<div style={{display: 'flex',justifyContent:'center'}}>
                {
                    Object.keys(chartData).map((key)=>{
                        return (
                            <div style={{display:'inline-block',minWidth: '100px'}}>
                                <div style={{display:'inline-block',width:' 30px', height:' 20px', backgroundColor: chartData[key].lineColor}} />
                                <span>{key}</span>
                            </div>
                        )
                    })
                }
</div>

动画实现

多折线图实现,修改了之前护理数据源的方法

getData(){
    const {data} = this.props;
    // data格式[{value:0,label: 'data1',class: '分类1'},{value:1, label: 'data2',class: '分类1'},{value:6,label: 'data1',class: '分类2'},{ value:4,label: 'data2',class: '分类2'}]
    const classAry = [];
    const chartData = {};
    let maxLength = 0;
    let maxValue = 0;
    data.map(a=>{
        if( classAry.indexOf(a.class) === -1){
            classAry.push(a.class);
            chartData[a.class] = {
            	data:[a], // 具体数据
              show: true, // 是否隐藏,用于图例点击
              isAnmation: true // 是否展示过度动画
              }
        }else {
            chartData[a.class].data.push(a);
        }
        if (a.value > maxValue){
            maxValue = a.value;
        }
    })
    Object.values(chartData).map(a=>{
        if (a.data.length> maxLength){
            maxLength = a.data.length
        }
    })
    this.setState({
        classAry,
        chartData,
        maxLength,
        maxValue
    },()=>{
        this.renderChart()
    })
  }

图例点击隐藏/展示折线

onClick={()=>{
    const dataInfo = {...chartData};
    dataInfo[key].show = !dataInfo[key].show; // 点击时更换展示/隐藏状态
    dataInfo[key].isAnmation = true; // 并且把当前数据设置展示过度动画
    this.setState({
        chartData: {...dataInfo}
    },()=>{
        this.renderChart() // 重新绘制图
    })
 }}

###折线图过度动画的实现方式(setInterVal和requestAnimationFrame)

  • setInterval(卡顿)
let index = 0;// 数据索引
    if(isAnmation){ // 通过当前分类的图是否有过渡动画来判断
    		let time = setInterval(()=>{
        	draw();
        }, 总时间/ (data.length-1))//总时间不变,间隔为每段折线绘制的时间
        function draw(time){
            if(index < (data.length-1)){
                ctx.strokeStyle = color;
                ctx.lineWidth= 1;
                ctx.beginPath();
                ctx.moveTo(padding + xLength*(index + 1),height - padding - data[index].value*yLength);
                ctx.lineTo(padding + xLength * (index+2),height - padding - data[index+1].value*yLength);
                ctx.fillText(data[index].value, padding + xLength*(index + 1), height - padding - data[index].value*yLength - 10);
                ctx.stroke();
                index = index +1
            }else {
            	clearInterval(time);
            }
        }
        requestAnimationFrame((time)=>draw(time));
    }else{
        // 最开始的绘制方法
    }
  • requestAnimationFrame(动画平滑,但是很快,可考虑继续细分增加过渡效果)
function draw(time){
		console.log(time); // 通过打印可以看出每次调用requestAnimationFrame间隔在16ms
    if(index < (data.length-1)){
        ctx.strokeStyle = color;
        ctx.lineWidth= 1;
        ctx.beginPath();
        ctx.moveTo(padding + xLength*(index + 1),height - padding - data[index].value*yLength);
        ctx.lineTo(padding + xLength * (index+2),height - padding - data[index+1].value*yLength);
        ctx.fillText(data[index].value, padding + xLength*(index + 1), height - padding - data[index].value*yLength - 10);
        ctx.stroke();
        index = index +1
        requestAnimationFrame((time)=>draw(time));
    }
    requestAnimationFrame((time)=>draw(time));
}
  • 完善动画过程
let i = Math.floor(index);
 let index = 0;
    let currentX=0,currentY=0,preX=0,preY=0; // 分别为 当前点的坐标 以及上一次到达位置的坐标
            let nextI = Math.ceil(index);
            let x = 0.167;  // 通过x设置动画快慢         
            pretime = time;
            
            if(index === 0){
                preX = padding + xLength; 
                preY = height - padding - data[0].value*yLength;
                currentX = padding + xLength + xLength*x;
                currentY = preY - (data[1].value - data[0].value)*yLength*x; // 在两个点之间按照x的比例进行移动
                ctx.moveTo(preX,preY);
                ctx.lineTo(currentX, currentY);
                ctx.fillText(data[i].value, padding + xLength, height - padding - data[0].value*yLength - 10);
                ctx.stroke();
                preX = currentX; 
                preY = currentY;
                index = index + x; 
                requestAnimationFrame((time)=>draw(time));
            }else if(i < (data.length-1)){
                let isRenderText = false;
                if((nextI-index)<x && index !==0 && (nextI-index)!==0){ // 判断如果当前点到下一个数据索引小于x值的时候,下一个lineTo的位置就是nextI
                    x= nextI - index;
                    isRenderText = true;
                }
                currentX = preX + xLength*x;
                currentY = preY -(data[i+1].value - data[i].value)*yLength*x;
                ctx.moveTo(preX,preY);
                ctx.lineTo(currentX,currentY);
                if(isRenderText){
                    ctx.fillText(data[i].value, padding + xLength*(i + 1), height - padding - data[i].value*yLength - 10);
                }
                ctx.stroke();
                preX = currentX;
                preY = currentY;
                index = index + x
                requestAnimationFrame((time)=>draw(time));
            } else if (i === data.length-1){
               	// 判断到达最后一个点的时候进行绘制数值
                ctx.moveTo(preX,preY);
                ctx.lineTo(padding + xLength * (i+1),height - padding - data[i].value*yLength);
                ctx.fillText(data[i].value, padding + xLength*(i + 1), height - padding - data[i].value*yLength - 10);
                
                ctx.stroke();
            }
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值