携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 2 天,点击查看活动详情
截止目前为止,事件线组件已经更新20个版本,被下载了1000多次。秉承着:该组件应该满足更多常见业务场景的理念,更新了趋势图双轴,多折线,重构配置项,优化性能等功能,并修改一些样式和bug。
所以耽误了些时间,今天开始向介绍下事件线组件的具体实现,希望对大家有所帮助,也欢迎大家指正。
上一篇已经介绍了组件库的搭建,没有看过的朋友,可以先看下第一篇内容。
传送门:multi-event-line
功能拆解
老子说:天下大事必作于易,天下难事必作于细。当我们在接到一个复杂的需求时,可以把这个需求拆解成多个层次,每个层次也可以拆解出多个小功能。
比如说这个事件线可以拆解成三个层次:
一、绘制基础图形 —— 事件线和趋势图;
二、滑动、拖动等动画实现;
三、Tooltip、点击等交互事件和反馈;
当把一个复杂的事件线拆解成几个层层递进的层次后,自己大概就知道自己的知识盲点,技术难点,从而利用搜索引擎补上。接下来分析下这三个层次:
第一层:绘制基础图形;相比较而言比较简单,只需要了解相关API,在合适的位置绘制出来就可以了,前期在绘制时可以先一切从简,先不考虑性能、动画等方面,当然如果对Canvas很熟练的话当然从一开始就考虑性能;
第二层:滑动等动画实现;这一块需要了解Canvas实现动画的原理,动画最基本的实现方式就是:重绘,就和看电影一样;当重绘的足够快(帧率大于25)时就可以骗过人眼,从而实现动画交互。所以这一块会损耗性能最大,是优化性能的核心层次,这个将作为下一篇的内容来讲;
第三层:Tooltip、点击等交互事件和反馈;这里需要用到Canvas一些常用的事件监听,还有和html混合使用等,难度适中,基本百度可以实现;
好了,废话就讲这么多,今天重点是介绍上面的第一个层次 —— 绘制基础图形,当然这里的基础图形不是针对Canvas而言的,而是针对事件线组件来说,所以大概拆分成一下几个图形:
- 事件类型 —— 矩形绘制;
- 事件 —— 圆角矩形绘制;
- X轴、刻度以及时间 —— 数据源分析、刻度、文本绘制;
- 辅助线 —— 虚线绘制;
- 趋势图左右Y轴 —— 折线数据分析、刻度和文本绘制;
- 多折线绘制 —— 连续线段绘制;
- 图例绘制 —— 多矩形和文本,从右向左绘制;
- 趋势图标题 —— 文本垂直绘制;
大概把整个实现线分成上面几个小功能,其中涉及Canvas坐标系,矩形、直线、曲线、文本等绘制,这些百度一下都能找到,下面就大概总结一下,当作基础知识储备:
基础知识储备:
- Canvas坐标系,左上角是原点(0, 0)。
- 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
});
};
这样就可以得到直角矩形的事件,如果要用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就可以填充和描边了。
这样就基本完成了事件线的绘制,当然这只是基础的绘制,还不能滑动和响应事件,一步一步来。
尾声
今天就先到这里,码字不易,如果觉的有用,欢迎点赞支持一下;如果发现有什么不对,欢迎在评论区留言;如果想看下面趋势图或滑动等交互的实现,👏欢迎关注一下不迷路。