点击关注公众号,“技术干货” 及时达!
效果图实现展示
为什么实现这个功能?
canvas大部分用于画图设计之类的功能,大厂用 canvas 用于业务实现,新颖性。 大厂的这些用于商用的功能实现都不开源,自我实现具有挑战性。
功能分析
主体展示 :日历表的绘制 功能增强 :任务分配进度的绘制 用户交互 :拖动调整任务时段 | 模拟 div 的 hover 效果等等...
技术选型
腾讯文档用的 konva,我们复刻。 使用 konva本身进行实现,不使用 react-konva 和 vue 相关的库,可以跨框架接入。
实现设计
传递基础配置实例化对象,利用发布订阅暴露出去重要的事件,用于使用者接收。
export interface KonvaCalendarConfig {
// 模式 可读 | 可修改 ( 影响拖动是否可修改日期 )
mode: 'read' | 'edit';
// 挂载节点
container: string;
// 初始时间
initDate?: Date | 'string'
}
const bootstrap = new CanvasKonvaCalendar({ mode: 'edit', container: '.smart-table-root',initDate : new Date('2024-10-20') })
bootstrap.setData([{startTime: '2024-09-30',
endTime: '2024-09-30',
fill: 'rgba(49, 116, 173,0.8)',
description: '1',id: uuid()}])
bootstrap.on('ADDTASKRANGE', (day: string) => {
console.log('点添加日期', day);
})
bootstrap.on('CLICKRANGE', (day: string) => {
console.log('选择日期', day);
})
内部实现详情 | 初始化数据
export class CanvasKonvaCalendar {
static readonly daysOfWeek = ['周一', '周二', '周三', '周四', '周五', '周六', '周天'];
// 渲染日历 layer ( 节点稳定 性能损失较小 )
private readonly layer: Konva.Layer;
// 用户交互 layer ( 节点变更频繁 )
private readonly featureLayer: Konva.Layer;
// 画布
private readonly stage: Konva.Stage;
// horve group 实例
private hoverGroup!: Konva.Group;
private readonly cellWidth: number;
private readonly cellHeight: number;
// x轴绘制起点
private readonly startX = 20;
private readonly emitter = new CalendarEvent();
private readonly hoverRect = { x: 0, y: 0, id: '' }
// 渲染任务进度的数据源
taskRanges =[];
// 当前日期
private date: Date = new Date();
private stringDate: string;
private month: number;
private year: number;
// 记录 拖动任务group 一些坐标信息
private recordsDragGroupRect: DragRect = {
// 鼠标点击开始拖动位置与 range x 差值
differenceX: 0,
// 鼠标点击开始拖动位置与 range y 差值
differenceY: 0,
// 拖动源 原始值x
sourceX: 0,
// 拖动源 原始值y
sourceY: 0,
// 拖动源
targetGroup: null,
// 鼠标开始拖动位置
startX: 0,
// 鼠标开始拖动位置
startY: 0
}
// 拖动 任务group实例
private dragGroup: Konva.Group | null = null;
private readonly stageHeight: number;
constructor(
private readonly config: KonvaCalendarConfig,
) {
this.stage = new Konva.Stage({
width: innerWidth - 350,
height: innerHeight - 170,
x: 0,
y: 0,
container: this.config.container
});
this.layer = new Konva.Layer({});
this.featureLayer = new Konva.Layer({});
this.stage.add(this.layer);
this.stage.add(this.featureLayer);
this.date = new Date(this.config.initDate || new Date());
this.stringDate = formatDate(this.date);
this.month = this.date.getMonth();
this.year = this.date.getFullYear();
(this.stage.container().children[0] as HTMLDivElement).style.background = '#FFF';
const { width, height } = this.stageRect;
this.stageHeight = height;
this.cellWidth = (width - 40) / 7;
this.cellHeight = (height - 60 - 30) / 5;
this.registerEvents();
this.draw();
}
// 初始绘制
private draw(): this {
this.drawCalendar(this.month, this.year);
this.drawHoverGroup();
this.drawTaskProgress();
return this;
}
绘制日历
采用周一至周天的顺序进行绘制,一列七天 一个月显示 5行的形式。 这样大概率会出现三个月的时间交叉,所以要以本月为基础同时绘制上月和下月在这个视图中出现的日期
// 绘制日历
private drawCalendar(month: number, year: number): void {
// 清空图层
this.layer.removeChildren();
const { firstDay, daysInMonth } = this.getDaysInMonth(month, year);
// 绘制当前显示的年份
const headerGroup = new Konva.Group({ name: 'header' })
const yearRect = new Konva.Rect({
x: 0,
y: 0,
width: this.stage.width(),
height: 40,
fill: 'white',
strokeWidth: 1,
});
const yearText = new Konva.Text({
x: 0,
y: 10,
text: `${year}年${month + 1}月`,
fontSize: 20,
fontFamily: 'Calibri',
fontStyle: 'bold',
width: 120,
align: 'center',
})
headerGroup.add(yearRect, yearText);
this.layer.add(headerGroup);
// 绘制每个星期的标题
CanvasKonvaCalendar.daysOfWeek.forEach((day, index) => {
const backgroudRect = new Konva.Rect({
x: index * this.cellWidth + this.startX,
y: 40,
width: this.cellWidth,
height: 30,
fill: 'white',
strokeWidth: 1,
})
const text = new Konva.Text({
x: index * this.cellWidth + this.startX,
y: 50,
text: day,
fontSize: 13,
fontFamily: 'Calibri',
// fill: 'black',
fill: 'rgba(0,0,0,0.9)',
width: this.cellWidth,
align: 'center',
});
this.layer.add(backgroudRect, text);
});
// 计算偏移量
const startOffset = (firstDay.getDay() + 6) % 7; // 计算偏移,周一为0
const lastMonth = month === 0 ? 11 : month - 1;
const lastMonthYear = month === 0 ? year - 1 : year;
const { daysInMonth: lastDaysInMonth } = this.getDaysInMonth(lastMonth, lastMonthYear);
// 渲染上一个月的日期
for (let i = 0; i < startOffset; i++) {
const day = lastDaysInMonth - startOffset + 1 + i; // 计算上一个月的日期
const x = i * this.cellWidth + this.startX;
const id = `${year}-${month}-${(day)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1');
const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id));
const group = new Konva.Group({ name: 'dateCell', id: id, x: x, y: 70 });
const rect = new Konva.Rect({
x: 0,
y: 0,
width: this.cellWidth,
height: this.cellHeight,
fill: '#fff',
stroke: '#E1E2E3',
strokeWidth: 1,
});
const text = new Konva.Text({
x: 10,
y: 10,
text: day.toString(),
fontSize: 20,
fontFamily: 'Calibri',
fill: 'gray', // 用灰色标记上个月的日期
});
const chineseText = new Konva.Text({
x: this.cellWidth - 40,
y: 13,
text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese,
fontSize: 13,
fontFamily: 'Calibri',
fill: 'rgba(0,0,0,0.4)',
});
group.add(rect, text, chineseText);
this.layer.add(group);
}
// 渲染当前月份的日期
for (let i = 0; i < daysInMonth; i++) {
const x = (i + startOffset) % 7 * this.cellWidth + this.startX;
const y = Math.floor((i + startOffset) / 7) * this.cellHeight + 40 + 30; // + cellHeight 为下移一行
const id = `${year}-${month + 1}-${(i + 1)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1');
const group = new Konva.Group({ name: 'dateCell', id, x, y });
const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id));
const activeDate = this.stringDate === id;
const rect = new Konva.Rect({
x: 0,
y: 0,
width: this.cellWidth,
height: this.cellHeight,
fill: '#fff',
stroke: '#EEE',
strokeWidth: 1,
});
let Circlex = 20;
let Circley = 20;
let CircleRadius = 13;
let fontSize = 20;
let textContext = (i + 1).toString();
if (textContext === '1') {
textContext = month + 1 + '月' + (i + 1) + '日';
fontSize = 15;
CircleRadius = 15
}
// 命中当前日期
const circle = new Konva.Circle({
x: Circlex,
y: Circley,
radius: CircleRadius,
fill: activeDate ? '#1f6df6' : '#FFF',
stroke: activeDate ? '#1f6df6' : '#FFF',
strokeWidth: 1,
});
const text = new Konva.Text({
x: textContext?.length > 1 ? 10 : 15,
y: textContext?.length > 1 ? 12 : 10,
text: textContext,
fontSize: fontSize,
fontFamily: 'Calibri',
fill: activeDate ? '#FFF' : 'black',
width: this.cellWidth - 20,
fontStyle: 'bold',
// align: 'center',
});
// 添加月份名称
const monthText = new Konva.Text({
x: x,
y: y + this.cellHeight / 2, // 调整位置以显示月份
text: new Date(year, month).toLocaleString('default', { month: 'long' }),
fontSize: 14,
fontFamily: 'Calibri',
fill: 'black',
width: this.cellWidth,
align: 'center',
});
const chineseText = new Konva.Text({
x: this.cellWidth - 40,
y: 13,
text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese,
fontSize: 13,
fontFamily: 'Calibri',
fill: 'rgba(0,0,0,0.4)',
});
group.add(rect, circle, text, chineseText);
const { y: groupY, height } = group.getClientRect();
if (groupY + height < this.stageHeight) {
this.layer.add(group);
group.moveToTop();
}
}
// 渲染下一个月的日期
const endOffset = (daysInMonth + startOffset) % 7;
for (let i = 0; i < (7 - endOffset) % 7; i++) {
const day = i + 1; // 下个月的日期
const x = (daysInMonth + startOffset + i) % 7 * this.cellWidth + this.startX;
const y = Math.floor((daysInMonth + startOffset) / 7) * this.cellHeight + 40 + 30;
const id = `${year}-${month + 2}-${(day).toString().padStart(2, '0')}`;
const group = new Konva.Group({ name: 'dateCell', id, x, y });
const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id));
const rect = new Konva.Rect({
x: 0,
y: 0,
width: this.cellWidth,
height: this.cellHeight,
fill: '#fff',
stroke: '#E1E2E3',
strokeWidth: 1,
});
const text = new Konva.Text({
x: 10,
y: 10,
text: day.toString(),
fontSize: 24,
fontFamily: 'Calibri',
// 用灰色标记下个月的日期
fill: 'gray',
align: 'center',
});
const chineseText = new Konva.Text({
x: this.cellWidth - 40,
y: 13,
text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese,
fontSize: 13,
fontFamily: 'Calibri',
fill: 'rgba(0,0,0,0.4)',
});
group.add(rect, text, chineseText);
const { y: groupY, height } = group.getClientRect();
if (groupY + height < this.stageHeight) {
this.layer.add(group);
}
}
}
着重讲解一下 任务进度的渲染 (最复杂的部分)
// 假如任务队列中数据 下面贴出任务的展示形式
taskRanges = {
startTime: '2024-10-01',
endTime: '2024-10-20',
fill: '#fff5cc',
description: '1',
id: '12345' || uuid()
},
任务进度的渲染使用的 konva.rect, 那么一个 rect 只能表示某一周中的时间范围。而视图中却显示了三个 rect。由此我们能分析出,每一个跨周的时间任务需要切割成一周一周的任务来渲染
。由此上面taskRanges就需要被切割。分割成不同的小块 range 后,需要与原始 range 形成关联 ,"origin": "12345"
标注出父节点的 id,用于后续修改|拖动|点击 | 查找 | 删除 原始range。具体如何分割的不是重点,后面可以查看 github 源码。
[
{
"startTime": "2024-10-01",
"endTime": "2024-10-06",
"fill": "rgba(0, 0, 255, 0.3)",
"description": "3 ",
"origin": "12345",
"id": "bb47c948-6ab0-4a47-8adf-13f8ed952643",
"day": 19
},
{
"startTime": "2024-10-07",
"endTime": "2024-10-13",
"fill": "rgba(0, 0, 255, 0.3)",
"description": "3 ",
"origin": "12345",
"id": "bb47c948-6ab0-4a47-8adf-13f8ed952643",
"day": 19
},
{
"startTime": "2024-10-14",
"endTime": "2024-10-20",
"fill": "rgba(0, 0, 255, 0.3)",
"description": "3 ",
"origin": "12345",
"id": "bb47c948-6ab0-4a47-8adf-13f8ed952643",
"day": 19
}
]
接下来是循环渲染分割块任务,需要确定任务块的起始坐标和宽度。通过去日历渲染 layer 层查找 range 的起点时间为 id 去 找到对应的 group 就能拿到在画布中的起始坐标 ,宽度很好计算,只需要知道每天占用的宽度 * range 的结束时间 - 开始时间的天数 const width = dayCount * this.cellWidth;
// 绘制日历的时候确定好层级关系 Group 包裹 (rect text)
const id = `${year}-${month + 1}-${(i + 1)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1');
// 用当天的时间作为 id 方便后续查找
const group = new Konva.Group({ name: 'dateCell', id, x, y });
// 查找
const group = this.layer.find(`#${range.startTime}`)[0];
if (!group) return;
const groupRect = group.getClientRect();
// 遍历任务数据
expectedResult.forEach((range) => {
const startDate = new Date(range.startTime).toISOString().split('T')[0];
const endDate = new Date(range.endTime).toISOString().split('T')[0];
// 计算任务跨越的天数,用于绘制宽度
const dayCount = Math.abs(this.calculateDaysDifference(range.endTime, range.startTime)) + 1;
const width = dayCount * this.cellWidth;
// 查找任务开始日期的组
const group = this.layer.find(`#${range.startTime}`)[0];
if (!group) return;
const groupRect = group.getClientRect();
// 查找合适的 yOffset
let yOffset = 0;
for (let [offset, dates] of sizeMap) {
let overlap = false;
// 检查当前 yOffset 是否有日期重叠
for (let dateRange of dates) {
if ((startDate >= dateRange[0] && startDate <= dateRange[1]) ||
(endDate >= dateRange[0] && endDate <= dateRange[1]) ||
(startDate <= dateRange[0] && endDate >= dateRange[1])) {
overlap = true;
break;
}
}
// 如果没有重叠,使用当前的 yOffset,并将日期插入该 yOffset 的数组
if (!overlap) {
yOffset = offset;
dates.push([startDate, endDate]);
break;
}
}
// 绘制任务
const rect = new Konva.Rect({
x: groupRect.x + 10,
y: groupRect.y + yOffset,
width: width - 10,
height: 20,
fill: range.fill || '#f3d4d4',
stroke: range.fill || '#f3d4d4',
opacity: 1,
strokeWidth: 1,
cornerRadius: [3, 3, 3, 3]
});
const text = new Konva.Text({
x: groupRect.x + 15,
y: groupRect.y + yOffset + 5,
text: range.description || '无',
fontSize: 12,
fill: 'rgba(0,0,0,0.8)'
// fill : 'white'
});
// 创建 Konva 组并添加任务矩形和文本
const taskProgressGroup = new Konva.Group({
name: `task-progress-group ${range.origin}`,
id: range.id,
day: range.day
});
taskProgressGroup.add(rect, text);
this.featureLayer.add(taskProgressGroup);
taskProgressGroup.moveToTop();
});
同一周存在多个时间段的处理 ( 也是有点难度的 ) 有些任务的时间范围跨度比较长,经过的时间多
,所以我决定对任务进行时间的先后顺序排序 并且优先绘制时间跨度的长的任务
。但是仅仅如此的话 多个任务相交的时间点任务显示会被重叠 所以还要处理任务的 y轴 位置。仔细看下面代码中的注释解释:
假如数据源为
taskRanges =[
{
startTime: '2024-10-01',
endTime: '2024-10-20',
fill: 'rgba(0, 0, 255, 0.3)',
description: '3 ',
id: uuid()
},
{
startTime: '2024-10-04',
endTime: '2024-10-05',
fill: 'pink',
description: '2 ',
id: uuid()
},
{
startTime: '2024-10-10',
endTime: '2024-10-12',
fill: '#caeadb',
description: '4',
id: uuid()
},
{
startTime: '2024-10-04',
endTime: '2024-10-04',
fill: 'rgba(214,241,255,0.6)',
description: '555',
id: uuid()
},
];
// 在渲染任务进度的代码中 我定义了
// 初始化 sizeMap,用于管理不同 yOffset 对应的日期范围
const sizeMap的作用
假如 range= new Map<number, string[][]>([
[35, []],
[65, []],
[95, []],
[125, []]
]);
我简单解释下这个sizeMap的作用
假如 range1 = { startTime: '2024-10-01', endTime: '2024-10-04' }
那么在渲染的时候 我会将经过的时间都存储进去
结果就是 range= new Map<number, string[][]>([
[35, [ '2024-10-01' , '2024-10-02' ,'2024-10-03' ,'2024-10-04' ]],
[65, []],
[95, []],
[125, []]
]);
渲染第二条 range的时候 假如 range2 = { startTime: '2024-10-03', endTime: '2024-10-04' }
我就会先去找到 y 坐标为 35 中是否存在 startTime 如果存在那么就会赋值 y :75 并且得到新的
range= new Map<number, string[][]>([
[35, [ '2024-10-01' , '2024-10-02' ,'2024-10-03' ,'2024-10-04' ]],
[65, [ '2024-10-03' , '2024-10-04'],
[95, []],
[125, []]
]);
以此类推 多个 range 就算交叉也不会重叠
注释:一个时间的 cell 最多展示 3 个 range 便可, 超过就不要渲染了 可在左下方渲染一个文字提示,这块我目前还没去实现 也不是很复杂。只需要 判断某个range的起点时间 在 rangeMap 中 35 65 95 都存在了 就不渲染了
用户交互部分 拖动调整时间范围 (借助几个事件 )
mousedown 确定目标 rang 的信息 dragMousemove 将已经渲染的 目标range 设置一个低透明度,并且 this.recordsDragGroupRect.targetGroup!.clone()
克隆一个 range 对象,添加到用户交互的layer 涂层上this.featureLayer.add(this.dragGroup);
只需要控制这个克隆后的 range 在画布中移动的 x y 距离就行。mouseup 还原一些临时数据,和确定时间更变的信息 进行修改 并且发布事件 range 调用者
下面的代码中有好几处调用了这个函数 我来解释下通过鼠标的xy 坐标 | 指定 某个 xy 坐标在 哪个layer层去查找 name 的 group
mouseup就需要接住这个函数 鼠标抬起 拿到 xy 去判断在当前哪个时间上。
const sorceDate = this.findGroup(this.layer, '.dateCell', {
x: this.recordsDragGroupRect.startX,
y: this.recordsDragGroupRect.startY
});
// 通过鼠标坐标 查找某个图层的元素
private findGroup(
layer: Konva.Layer,
findkey: string,
pointerParam?: Vector2d | null
) {
const pointer = pointerParam || this.stage.getPointerPosition()!;
const taskGroups = layer.find(findkey) as Konva.Group[];
if (!taskGroups.length) {
return;
}
for (let i = 0; i < taskGroups.length; i++) {
const group = taskGroups[i];
const rect = group.getClientRect();
if (haveIntersection(rect, pointer)) {
return { group, rect, pointer };
}
}
}
private mousedown(): void {
if (this.config.mode === 'read') {
return;
}
const result = this.findGroup(this.featureLayer, '.task-progress-group');
if (!result) {
return;
}
const { group, pointer, rect } = result;
this.recordsDragGroupRect = {
differenceX: pointer.x - rect.x,
differenceY: pointer.y - rect.y,
sourceX: rect.x,
sourceY: rect.y,
targetGroup: group,
startX: pointer.x,
startY: pointer.y
}
}
private dragMousemove(): void {
if (this.config.mode === 'read') {
return;
}
if (this.recordsDragGroupRect.differenceX === 0 && this.recordsDragGroupRect.differenceY === 0) {
return;
}
// 拖动中
if (!this.dragGroup) {
this.dragGroup = this.recordsDragGroupRect.targetGroup!.clone();
this.recordsDragGroupRect.targetGroup!.opacity(0.3);
this.hoverGroup.children[0].setAttr('fill', 'rgba(237,244,255,0.8)');
this.hoverGroup.moveToBottom();
this.featureLayer.add(this.dragGroup);
}
const pointer = this.stage.getPointerPosition()!;
this.dragGroup.setAttrs({
x: pointer.x - this.recordsDragGroupRect.differenceX - this.recordsDragGroupRect.sourceX,
y: pointer.y - this.recordsDragGroupRect.differenceY - this.recordsDragGroupRect.sourceY
})
}
private mouseup(): void {
if (this.config.mode === 'read') {
return;
}
if (this.dragGroup) {
// this.hoverGroup.children[0].setAttr('fill', 'rgba(0, 0, 0, 0.053)');
// 拖动结束
const sorceDate = this.findGroup(this.layer, '.dateCell', {
x: this.recordsDragGroupRect.startX,
y: this.recordsDragGroupRect.startY
});
const targetDate = this.findGroup(this.layer, '.dateCell');
if (!targetDate || !sorceDate) {
return;
}
const sorceDateId = sorceDate.group.attrs.id;
const targetDateId = targetDate.group.attrs.id;
// 选择时间相同
if (sorceDateId === targetDateId) {
this.recordsDragGroupRect.targetGroup!.opacity(1);
} else {
console.log('this.recordsDragGroupRect', this.recordsDragGroupRect);
const { day = 0, id } = this.recordsDragGroupRect.targetGroup?.attrs
const arratItem = this.taskRanges.findIndex((item) => item.id === id);
if (arratItem >= 0) {
const endTime = this.addDays(targetDateId, day);
console.log('=====>', day, sorceDateId, targetDateId, targetDateId, endTime);
this.taskRanges[arratItem].startTime = targetDateId;
this.taskRanges[arratItem].endTime = endTime;
this.drawTaskProgress();
}
}
this.dragGroup.remove();
}
this.recordsDragGroupRect = {
differenceX: 0,
differenceY: 0,
sourceX: 0,
sourceY: 0,
startX: 0,
startY: 0,
targetGroup: null
}
this.dragGroup = null;
}
向外界暴露一些功能 ,还有一些用户交互的代码 比如鼠标移入某个时间可以高亮 | 还能在某个时间点击添加任务。这些不多做赘述 看一后面参考代码还有一些功能 例如拖动延长任务时间什么的
可以在一有代码的参考下 自行实现下。也算是给大家一点挑战性。
//. 更新任务
setData(ranges: Range[]): this {
this.taskRanges = ranges;
this.draw();
return this;
}
// 自定义的内部事件派发 事件监听
on(key: EventType, callback: any): this {
this.emitter.on(key, callback);
return this;
}
// 将任务表 转成图片
downImage(config: Parameters<Konva.Stage['toImage']>[number]): void {
this.stage.toImage({
pixelRatio: 2,
callback: (image) => {
const link = document.createElement('a');
link.href = image.src;
link.download = 'image.png';
link.click();
},
...config
})
}
// 下一个月
nextMonth(): void {
this.month++;
if (this.month > 11) {
this.month = 0;
this.year++;
}
this.featureLayer.removeChildren();
this.draw();
}
// 今天
today(): void {
this.month = new Date().getMonth();
this.year = new Date().getFullYear();
this.featureLayer.removeChildren();
this.draw();
}
// 上一个月
prevMonth(): void {
this.month--;
if (this.month < 0) {
this.month = 11;
this.year--;
}
this.featureLayer.removeChildren();
this.draw()
}
结束语
某些逻辑实现可能不是最佳实践,多多包涵。可以等开源后自行修改源码。
目前这个代码我考虑优化后 近几天 开源发布 npm 包。
更新 :
npm包 :https://www.npmjs.com/package/konva-calendar
github : https://github.com/ayuechuan/konva-calendar
点击关注公众号,“技术干货” 及时达!