Canvas定制组件——事件线之事件的绘制

1,598 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 2 天,点击查看活动详情

截止目前为止,事件线组件已经更新20个版本,被下载了1000多次。秉承着:该组件应该满足更多常见业务场景的理念,更新了趋势图双轴,多折线,重构配置项,优化性能等功能,并修改一些样式和bug。

所以耽误了些时间,今天开始向介绍下事件线组件的具体实现,希望对大家有所帮助,也欢迎大家指正。

上一篇已经介绍了组件库的搭建,没有看过的朋友,可以先看下第一篇内容。

传送门:multi-event-line

功能拆解

老子说:天下大事必作于易,天下难事必作于细。当我们在接到一个复杂的需求时,可以把这个需求拆解成多个层次,每个层次也可以拆解出多个小功能。

比如说这个事件线可以拆解成三个层次:

一、绘制基础图形 —— 事件线和趋势图;

二、滑动、拖动等动画实现;

三、Tooltip、点击等交互事件和反馈;

当把一个复杂的事件线拆解成几个层层递进的层次后,自己大概就知道自己的知识盲点,技术难点,从而利用搜索引擎补上。接下来分析下这三个层次:

第一层:绘制基础图形;相比较而言比较简单,只需要了解相关API,在合适的位置绘制出来就可以了,前期在绘制时可以先一切从简,先不考虑性能、动画等方面,当然如果对Canvas很熟练的话当然从一开始就考虑性能;

第二层:滑动等动画实现;这一块需要了解Canvas实现动画的原理,动画最基本的实现方式就是:重绘,就和看电影一样;当重绘的足够快(帧率大于25)时就可以骗过人眼,从而实现动画交互。所以这一块会损耗性能最大,是优化性能的核心层次,这个将作为下一篇的内容来讲;

第三层:Tooltip、点击等交互事件和反馈;这里需要用到Canvas一些常用的事件监听,还有和html混合使用等,难度适中,基本百度可以实现;

好了,废话就讲这么多,今天重点是介绍上面的第一个层次 —— 绘制基础图形,当然这里的基础图形不是针对Canvas而言的,而是针对事件线组件来说,所以大概拆分成一下几个图形:

  1. 事件类型 —— 矩形绘制;
  2. 事件 —— 圆角矩形绘制;
  3. X轴、刻度以及时间 —— 数据源分析、刻度、文本绘制;
  4. 辅助线 —— 虚线绘制;
  5. 趋势图左右Y轴 —— 折线数据分析、刻度和文本绘制;
  6. 多折线绘制 —— 连续线段绘制;
  7. 图例绘制 —— 多矩形和文本,从右向左绘制;
  8. 趋势图标题 —— 文本垂直绘制;

大概把整个实现线分成上面几个小功能,其中涉及Canvas坐标系,矩形、直线、曲线、文本等绘制,这些百度一下都能找到,下面就大概总结一下,当作基础知识储备:

基础知识储备:

  • Canvas坐标系,左上角是原点(0, 0)。
image.png
  • Canvas绘制基础理论:Canvas是基于状态的绘制,绘制前需要先规划路径设置状态
import React, {useEffect, useRef} from 'react';

export default ()=>{
    const canvas = useRef();
    
    useEffect(()=>{
        const context = canvas.current?.getContext('2d');
        context.beginPath();
        context.rect(100,100,200,200);
        context.stroke();
        context.fill();
    });

    return (
        <canvas ref={canvas} id='event-line'/>
    )
}
  • beginPath/moveTo/lineTo/rect/arc等API都只是在规划路径,真正绘制在画面上需要调用stroke和fill相关方法。
  • context就像一支画笔,沾颜料🎨设置strokeStyle/fillStyle等会一支停留在画笔上,所以日常使用时记得重新设置strokeStyle/fillStyle等进行覆盖,或者使用save/restore进行状态恢复。
  • 常用API

获取上下文环境context对象

// 从canvas的dom上,获取context上下文环境对象;
const context = canvasDom.getContext('2d');

规划路径

// 开始规划新路径,不粘连旧路径
context.beginPath();
// 记录当前context画笔状态
context.save()
// 将画笔移动到(x,y)坐标点
context.moveTo(x,y);
// 规划一条直线:从上一次画笔坐标连接到当前(x,y)点
context.lineTo(x,y);
// 规划一段圆弧:圆心(x,y),半径r,从x轴正方向0度起,顺时钟画一个弧度为2π的圆弧,2π就是整圆
context.arc(x,y,r,0,2*Math.PI);
// 规划一个矩形:左上角(x,y),沿着x轴正方向距离w宽,沿着y轴正方向距离h高
context.rect(x,y,w,h);
// 将规划路径自动闭环:自动封闭路径,和beginPath并不是天生一对哦
context.closePath();
// 将context画笔恢复到上次save的状态,和save是天生一对
context.restore();

设置路径样式style

// 线条宽度
context.lineWidth = 2;
// 设置描边style 
context.strokeStyle='red';
// 设置填充style
context.fillStyle='blue';

绘制在canvas上

// 绘制描边
context.stroke();
// 绘制填充
context.fill();

内置规划并绘制的复合API

// 绘制描边矩形
context.strokeRect();
// 绘制填充矩形
context.fillRect();
// 绘制描边文本 —— 空心
context.strokeText(text,x,y,maxWidth);
// 绘制填充文本 —— 正常文本
context.fillText(text,x,y,maxWidth);

基本上了解上面这些常用API,可以解决一般的需求了,当然还需要听说一些drawImage/贝塞尔曲线啥的,需要用的时候再去问度娘好了。

基础实现

事件线的x轴和时间类型时基于数据源配置,在具体实现时是需要单独分析所有数据的时间信息,类型信息,和值域信息的,这些将在具体实现时讲到。

1. 绘制事件类型 —— 矩形绘制

事件类型的绘制比较简单,就是几个X轴一样,宽高一样,Y根据次序变化的几个矩形堆叠在一起;

事件类型数据类型定义:IEventType[]

interface IEventType {
  value: string,
  label: string,
  sort: number
  primaryColor: string, // 事件类型主题色,边框文字颜色
  secondaryColor: string, // 事件类型次主题色,背景颜色
}

下面来看下具体的绘制事件类型方法:

import React, { useEffect, useRef, useCallback } from 'react';

export default ({ eventTypes })={
    const canvasRef = useRef<any>(null);

    const getContext = useCallback(() => {
      return canvasRef.current?.getContext('2d');
    }, [canvasRef.current]);

    const drawEventTypes = (x: number, y: number, w: number, h: number) => {
      const context = getContext();
      eventTypes.forEach(({ label, primaryColor, secondaryColor},i) => {
          context.rect(x, y + h*i, w, h); // y加上高度*下标,让矩形向下叠罗汉式
          context.strokeStyle = primaryColor;
          context.fillStyle = secondaryColor;
          context.stroke(); // 路径描边
          context.fill(); // 路径填充
          context.fillText(label, x , y, w); //文本最大宽度是矩形宽度w
      }); 
    };

    useEffect(()=>{
        drawEventTypes(0, 0, 100, 40)
    });

    return <canvas ref={canvasRef}/>
}

2. 绘制X轴 —— 刻度

当准备绘制事件的时候,发现和事件类型绘制相比,除了圆角矩形的区别,最大的区别就是事件的x轴是时间刻度,y轴是事件类型刻度,y轴还好说, 使用事件类型的次序乘以事件高度就可以计算出来, x轴是时间刻度,需要把时间和x坐标进行映射,所以要先绘制X轴。

要绘制X轴则需要,获取到最大日期和最小日期,这里就需要分析一下事件数据,获取最大日期和最小日期:

// 日期比较
const momentMin = (a, b) => moment(a).isBefore(b)? a: b;
const momentMax = (a, b) => moment(a).isBefore(b)? b: a;

const analysisEventData = (events) => {
  const { startMin, startMax, endMax } = events?.reduce(
    ({ startMin, startMax, endMax }, event) => {
      return {
        startMin: momentMin(startMin || event?.start, event?.start),
        startMax: momentMax(startMax || event?.start, event?.start),
        endMax: momentMax(endMax || 0, event?.end),
      };
    },
    {},
  );
  return {
    min: startMin,
    max: momentMax(startMax, endMax),
  };
};
  • reduce方法的使用:以前不知reduce好,错把map当成宝。可以把reduce理解为采蘑菇的小姑娘,初始化就是小姑娘的篮子里已经装了啥,循环体就是判断标准是寻找最大的蘑菇/最漂亮的蘑菇/统计蘑菇的个数等等: array.reduce(function(total, currentValue, currentIndex, arr), initialValue)
  • 第二个需要注意的是,事件可以没有结束日期,所以X轴最大日期可能是最后一个事件的开始日期;

拿到最大日期和最小日期,然后可以用moment-range插件,拿到所有的日期数组:

import Moment from 'moment';
import { extendMoment } from 'moment-range';
const moment = extendMoment(Moment as any);

const dateList = Array.from(
      moment.range(moment(minDate), moment(maxDate)).by('day'),
    ).map((item) => item.format('YYYYMMDD'));

拿到全部日期数组就可以进行X轴绘制了,在绘制刻度之前要考虑,刻度间距,年初/月初/以及均匀展示日期信息

export const drawAxisScale = (
  dateList,
  offsetX = 0,
  offsetY = 0,
) => {
  dateList.forEach((item: any, i: number) => {
     // 根据当前日期,判断是否年初,月初,刻度高度,颜色不同,以及是否在X轴展示日期
    const { height, color, text, textHalfWidth } = judgeDayScaleStyle(item);
    ctx.strokeStyle = color;
    ctx.lineWidth = scale.lineWidth;
    const startX = offsetX + i * 10;
    ctx.beginPath();
    ctx.moveTo(startX, offsetY);
    ctx.lineTo(startX, offsetY - height);
    ctx.stroke(); // 刻度线
    if (text) {
      ctx.textBaseline = 'middle';
      ctx.fillStyle = '#999';
      ctx.fillText(text, startX - textHalfWidth, offsetY + 12); // 刻度文本
    }
  });
};

const judgeDayScaleStyle = (text: string) => {
  const textSize = 12;
  const style = {
    height: 6,
    color: '3999',
    text: '',
    textHalfWidth: 0,
  };
  // 年初判断
  if (text.endsWith('0101')) {
    style.height = 12;
    style.color = '#333';
    style.text = moment(text).format('YYYY');
    style.textHalfWidth = (textSize * 4) / 2;
  // 月初判断
  } else if (text.endsWith('01')) {
    style.height = 8;
    style.color = '#666';
    style.text = moment(text).format('YYYY-MM');
    style.textHalfWidth = (textSize * 7) / 2;
  } else {
    // 日期信息
    const dayNum = parseInt(text.substring(text.length - 2));
    // 5号-26号展示文本,日起取余5等于0是才展示日期信息
    if (dayNum > 5 && dayNum < 26 && dayNum % 5 === 0) {
      style.color = secondColor;
      style.text = moment(text).format('MM-DD');
      style.textHalfWidth = (textSize * 5) / 2;
    }
  }

  return { ...style };
};

上面的实现方法只是一个简单实现,暂时能满足需求,限制比较大,只适用年月日格式时间。后期会考虑兼容时、分、秒,大家有什么好的意见可以在评论区提议一下。

3. 绘制事件 —— 圆角矩形绘制

现在有了刻度,就可以绘制事件了。

事件数据类型定义:IEvent[]

interface IEvent {
  start?: string; // 事件开始
  end?: string; // 事件结束
  title: string; // 事件标题
  detail?: string; // 事件详情
}

事件的绘制要考虑:事件矩形的高度不要超过事件类型的高度,事件的绘制和事件类型的绘制差不太多,就是矩形起始坐标/宽度是动态计算的:

  • x轴坐标是根据当前开始日期距离最小日期的距离➕x轴偏移量计算得来;
  • y轴坐标是根据当前事件类型的次序✖️事件类型高度➕y轴偏移量计算得出的;
  • 宽度是根据起始时间的天数差✖️刻度间距,或者是默认宽度;
  • 高度可以是固定的30,小于事件类型的高度40就可以;
   // offsetX,offsetY,起始坐标的偏移量
   const drawEvents = (offsetX: number, offsetY: number) => {
      const context = getContext();
      events.forEach((item, index) => {
        // 根据事件类型获取事件次序和主题颜色
        const { sort, primaryColor, secondaryColor } = eventTypes.find(
          ({ type }) => type === item?.type,
        );
        // 计算当前开始日期距离最小日期的天数,再乘以刻度
        const rectX = offsetX + moment(item?.start).diff(moment(minDate), 'days') * 10;
        // 事件Y轴坐标就是事件类型的Y轴坐标
        const rectY = offsetY + 40 * sort; // 使用事件类型高度
        // 计算事件持续多少天,乘以刻度,就是事件矩形的宽度, 没有结束日期就是默认宽度
        const reacW = moment(item?.end).diff(moment(item?.start), 'days') * 10 || 100;
        context.beginPath();
        // 直角矩形
        context.rect(rectX, rectY, reacW , 30); 
        // 圆角矩形, radius默认4px
        // roundRectPath(context,rectX, rectY, reacW , 30, 4);
        context.strokeStyle = primaryColor;
        context.fillStyle = secondaryColor;
        context.stroke(); // 路径描边
        context.fill(); // 路径填充
        context.fillText(item.title,rectX, rectY, reacW); //文本最大宽度是矩形宽度w
      });
   };
image.png

这样就可以得到直角矩形的事件,如果要用roundRectPath方法规划圆角矩形路径,还需要特殊定制下。直角矩形的实现其实是封装了4个边的绘制,而圆角矩形是在四个边➕四段圆弧组成

export const roundRectPath = (
  ctx: any,
  x: number,
  y: number,
  w: number,
  h: number,
  r: number = 0,
) => {
  ctx.beginPath();
  ctx.arc(x + r, y + r, r, Math.PI, (3 * Math.PI) / 2);
  ctx.lineTo(x + w - r, y);
  ctx.arc(x + w - r, y + r, r, (3 * Math.PI) / 2, 2 * Math.PI);
  ctx.lineTo(x + w, y + h - r);
  ctx.arc(x + w - r, y + h - r, r, 0, Math.PI / 2);
  ctx.lineTo(x + r, y + h);
  ctx.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI);
  ctx.closePath();
};

替换掉绘制事件中默认的rect矩形路径规划,然后调用fill和stroke就可以填充和描边了。

这样就基本完成了事件线的绘制,当然这只是基础的绘制,还不能滑动和响应事件,一步一步来。

尾声

image.png

今天就先到这里,码字不易,如果觉的有用,欢迎点赞支持一下;如果发现有什么不对,欢迎在评论区留言;如果想看下面趋势图或滑动等交互的实现,👏欢迎关注一下不迷路。