写在最前面
话不多说,先上图:(也可以直接点👇🏻 demo链接体验)
背景
为什么想到要做一个demo?
张三:"朋友你空的哦,写这么个玩意儿?工作量不饱和吧。😭😭😭。"
俺:"都是打工人,没事谁研究这东西,兜兜马路,看看剧(繁花最近蛮好的),美滋滋的动画片瞅瞅,它不香吗?😄😄😄。一切的起因还是因为四个字"上有需求"。而且通过一阵子的捣鼓下来,还挺有意思的,就把demo设计开发的过程记录下来分享给大家。包括怎么想的、做的,一步步的,知无不言。希望对有需要的朋友们有些助力。
目标和现状:
话归正题,说说Demo的目标和个人的现状:
目标
- 交互功能对标飞书 - 甘特图视图 + Notion - Timeline View (大概比重8-2开)
- 能支持大量数据的绘制 (比如10万条)
- 一定要用zrender,一定要用canvas (工作需要,实际可各种平替 可参考awesome-canvas)
- 通过调研,提前明确风险
现状
- 几乎0基础,从未正儿八经写过一行canvas的代码 (当然画画线、矩形、清空清空画布还是知道的)
- 工作中从没用过甘特图: (概念是有的,估时排期利器,总觉得有点high level是上头管人管项目用的,普通一线打工人似乎用不着)
梳理功能点
说干就干,第一步想到的就是梳理希望对标产品的所有交互和功能 ,因为是前端,重点从头到尾只考虑怎么画(实现)这一件事。这件事做完以后对要做的东西就有了比较具象的概念和更实实在在的体会。
梳理完功能以后,得出:
=> 甘特图大概可以分成这几块:
从组成元素上:
- 任务条(taskBar)
- 时间轴线(timeScale)
- 里程碑(mileStone)
- 今日线(todayLine)
- 日期格子(date grid)
从交互上:
交互 | 备注 |
---|---|
拖动任务的行蓝线 | |
hover日期的🚩 | |
hover格子的添加按钮 | |
任务尺寸调整 |
开始动手
熟悉zrender
因为有用zrender的硬性要求,所以这个地方的第一步自然跑去看这东西怎么用,好嘛。。。不看不知道,一看吓一跳。文档太简洁了,教程资料好少 😭,示例有6个,都不是很典型,API文档还算齐全(文档里这个帮助最大)。感兴趣的可以去看一眼。(文档首页、API、示例页)
因为不是专门介绍zrender,也不评价它是好不坏,只是一个工具库。为了后面帮助理解,简要说几个自己理解的点:
- 封装了很多shape图形,可以很简单的创建各种图形,常用的线、矩形、圆、复杂的还可以绘制svg的path(hover日期的🚩就是)。(支持的图形)
- 元素可以绑定监听常用的事件(类比DOM),可以方便的添加事件来实现功能,像常用的click, mouseover, mouseleave, drag等...
- 元素有包围盒的概念,可类比DOM元素的getBoundingClientRect,可以返回元素的位置和宽高信息
- 元素都可以设置自己的一大堆属性(类似DOM元素的style)
- 有一个zrender实例的概念(感觉类似canvas原生的2d上下文),可以往其中添加、删除绘制的元素来渲染
学习踩坑的过程就不过多表述了,推荐一个对熟悉zrender有帮助的仓库:github.com/MarkMindCkm...
开始demo
整体思路:
流程图:
参数:
js
// 格子:宽度
export const unitWidth = getParamsFromSearch() ?? 160;
// 格子:一半宽度
export const halfUnitWidth = unitWidth / 2;
// 任务:高度
export const barHeight = getParamsFromSearch('barHeight') ?? 30;
// 任务:底部外边距
export const barMargin = debug ? 10 : (getParamsFromSearch('barMargin') ?? 1);
// 任务:任务名左内边距
export const taskNamePaddingLeft = getParamsFromSearch('taskNamePaddingLeft') ?? 15;
// 整体:图表绘制的初始位置,距离左边距离
export const initChartStartX = 1
// 整体:图表绘制的初始位置,距离顶边距离
export const initChartStartY = 50;
// 日期时间轴:高度
export const timeScaleHeight = getParamsFromSearch('timeScaleHeight') ?? 20;
// 里程碑:高度
export const milestoneTopHeight = getParamsFromSearch('milestoneTopHeight') ?? 20;
是的,你看到了代码里有getParamsFromSearch
,所以其实在demo链接中你可以自己修改search param参数来修改初始参数看效果。例如:?barHeight=50, 点我看效果。
数据
实际这边只有两份数据,一份(tasks)表示所有任务条,一份(mileStones)表示所有的里程碑。
任务条:
js
{
name: "Task 1", // 任务名称
start: 0, // 任务开始的格子数 (基准是2024-01-01,即1-1是0,1-2是1以此类推)
duration: 3, // 任务占的格子数
resource: "John", // 任务指派人
fillColor: "#f00" // hex色值
}
里程碑:
js
{
name: '提测', // 里程碑名称
start: 10 // 开始的位置,同task的start逻辑
}
画UI
画布局
布局包括时间轴和网格:
时间轴:
准备工作:
- 在html里新建一个dom容器
<div id="zrender-container" style="height: 700px;"></div>
- 初始化一个zrender实例,绑定到「1」中新建的dom上
- js中引入上面的所有参数变量
- mock掉getParamsFromSearch方法,直接返回null
时间轴:
js
// margin left to the container
const chartStartX = initChartStartX;
// margin top to the container
const chartStartY = Math.max(initChartStartY, timeScaleHeight + milestoneTopHeight);
// 1. 拿到画布的宽
const canvasWidth = zr.getWidth();
// 2. 计算需要画多少格
const timeScaleWidth = Math.ceil((canvasWidth) / unitWidth);
// 3. 画时间轴的矩形,设置位置x,y,长宽,给一个背景色填充
const timeScale = new zrender.Rect({
shape: {
x: chartStartX,
y: chartStartY - timeScaleHeight,
width: timeScaleWidth * unitWidth,
height: timeScaleHeight
},
style: {
fill: "rgba(255, 0,0, .2)"
}
});
// 4. 加载zr实例中
zr.add(timeScale);
完事,就可以在页面上看到一个红色的长条矩形块了。
垂直的格子竖线:
竖线的长度依赖任务个数,所以先初始化tasks:
js
const tasks = [
{ name: "Task 1", start: 0, duration: 3, resource: "John", fillColor: getRandomColor() },
{ name: "Task 2", start: 2, duration: 4, resource: "Jane", fillColor: getRandomColor() },
{ name: "Task 3 long long long", start: 7, duration: 1, resource: "Bob", fillColor: getRandomColor() },
{ name: "Task 4", start: 8, duration: 2, resource: "Bose", fillColor: getRandomColor() },
{ name: "Task 5", start: 10, duration: 8, resource: "Uno", fillColor: getRandomColor() },
{}
// Add more tasks as needed
]
开始画竖线:
js
// 1. 计算竖线开始、结束的位置
const gridStartX = chartStartX;
const gridEndX = timeScaleWidth * unitWidth;
// 2. 计算要画多少根竖线,其实就是格子数 + 1
const gridLineCount = timeScaleWidth + 1;
// 3. 遍历要画的线的个数
for (let i = 0, count = 0; count < gridLineCount; i++,count++) {
const gridX = gridStartX + i * unitWidth;
// 4. 画一根线,从(x1, y1) -> (x2, y2)
const gridLine = new zrender.Line({
shape: {
x1: gridX,
y1: chartStartY - timeScaleHeight,
x2: gridX,
y2: chartStartY + (barHeight + barMargin) * tasks.length
},
style: {
stroke: "lightgray"
}
});
完事,就可以看到竖线了,竖线的间隔:unitWidth,高度:任务数量 * (任务高 + 任务外边距)
画日期文本
在画竖线的循环中加入:
js
// 1. 线比格子多1,所以要提前1步退出
if (count < gridLineCount - 1) {
// 2. 画基本文本,可以直接改成日期名字,这里直接写遍历的index
const dateText = new zrender.Text({
style: {
text: i,
// text: dateInfo.dateString,
x: gridX,
y: chartStartY - timeScaleHeight,
},
z: 1
});
// 3. 为了居中,要算出文本的宽高
const { width, height } = dateText.getBoundingRect()
// 4. 重新设置日期文本位置,居中
dateText.attr({
style: {
x: gridX - width / 2 + halfUnitWidth,
y: chartStartY - timeScaleHeight - height / 2 + timeScaleHeight / 2,
}
})
// 5. 加到zrender实例中
zr.add(dateText);
}
完事,日期文本就画出来了。(这边的日期实际可以通过2024-01-01为基准计算出来然后写实际日期,下面画休息日斜线会一起处理)
画休息日斜线
OK,前面都蛮顺利的,过程中第一次难住了,一共两个问题:
- 怎么给格子画平行斜线?
- 怎么判断是不是工作日?(有各种调休、放假等)
经过一番捣鼓,我直接说结论:
「问题1」:用hachure-fill
「问题2」:去timor.tech/api/holiday... ,获取2024年的所有数据,然后存本地holidays变量来判断
「问题1」捣鼓过程简述:
我有个特别喜欢的画板工具叫excalidraw,里面有类似的填充方式里有类似斜线的效果,然后它是用canvas绘制的,所以拉源码,一点点debug,找到依赖的包叫roughjs,然后继续挖roughjs依赖的画斜线方法,实际用的是一个叫hachure-fill的,破案,可行。
好了,备菜完了,开工~
1、存一个holidays变量,格式如下:
js
const holidays = {
...
"01-01": {
"holiday": true,
"name": "元旦",
"wage": 3,
"date": "2024-01-01",
"rest": 1
},
...
}
2、写一个判断是否是休息日的函数 isHoliday 逻辑很简单,优先判断是否在holidays变量里有结果,有则直接返回,否则判断是不是周六日。
js
function isHoliday(dateString) {
const d = new Date(dateString);
const month = (d.getMonth() + 1 + '');
const monthWithPadding = month.padStart(2, '0');
const day = (d.getDate() + '')
const dayWithPadding = day.padStart(2, '0')
const date_key = `${monthWithPadding}-${dayWithPadding}`;
const isWeekend = ([0,6].indexOf(d.getDay()) != -1);
if (holidays[date_key]) {
return {
isHoliday: holidays[date_key].holiday,
dateString: `${month}-${day}`
}
}
return {
isHoliday: isWeekend,
dateString: `${month}-${day}`
}
}
3、画竖线的循环中,画日期文本之前画日期斜线,其中hachureLines方法的使用,参考这里
js
// MARK: 同一个遍历画「休息日斜线」
const now = +new Date('2024-01-01');
const currentDate = now + i * 60 * 1000 * 60 * 24
const dateInfo = isHoliday(currentDate)
// 是休息日的话画斜线
if (dateInfo.isHoliday) {
try {
// 返回要画的线的开始、结束坐标
const lines = hachureLines([
[chartStartX + i * unitWidth, chartStartY],
[chartStartX + i * unitWidth + unitWidth, chartStartY],
[chartStartX + i * unitWidth + unitWidth, chartStartY + (barHeight + barMargin) * tasks.length],
[chartStartX + i * unitWidth, chartStartY + (barHeight + barMargin) * tasks.length]
], 10, 45);
// 用zrender画线段,描边上色
lines.forEach(line => {
const [x1, y1] = line[0]
const [x2, y2] = line[1]
const l = new zrender.Line({
shape: {
x1, y1, x2, y2
},
style: {
stroke: 'rgba(221, 221, 221, 0.7)'
}
})
zr.add(l)
})
} catch (error) {
console.log(error)
}
}
4、顺便,可以把之前的日期文本直接替换为真实的日期
js
const dateText = new zrender.Text({
...
text: dateInfo.dateString,
...
});
完事,效果如下:(1.1是元旦,画的是斜线!!!)
画任务条
舞台(布局)有了,主角(任务条)咋还不上场。。。马上开始!😎😎😎
已知任务条大致由下列元素组成:
- 一个表示跨度的矩形(Rect)
- 一段任务名文本(Text)
- 一段指派人文本(Text)
- 任务耗时天数(Text)
- 跨度矩形左右两边各一个调节尺寸的隐藏把手(Rect)
为了更好的聚合任务条的组成元素,这里用到了zrender里的组(group)来聚合上面的所有元素,添加到组里的元素位置x,y都是相对组(group)的相对位置,后续的拖拽操作位置只要修改整个组(group)的x、y即可。
js
// 1. 遍历tasks数组
tasks.forEach(function (task, index) {
// 2. 因为有最后一行是空行,没有任务,用来创建新任务,轮空不画
if (!task?.name) return
// 3. 计算任务的绘制位置和矩形宽高
const x = chartStartX + task.start * unitWidth;
const y = chartStartY + (barHeight + barMargin) * index;
const width = task.duration * unitWidth;
const taskBarRect = {
width,
height: barHeight
};
// 4. 建一个组,设置可以拖拽 (感谢这个属性,后续交互省了很多力气,还可以设置只能垂直或者水平拖动)
const group = new zrender.Group({
x,
y,
draggable: true // Enable draggable for the group
// draggable: "horizontal", // Enable draggable for the group
});
// 5. 创建任务跨度矩形
const rect = new zrender.Rect({
shape: {
x: 0,
y: 0,
width: width,
height: barHeight,
r: 6
},
style: {
fill: task.fillColor
},
cursor: 'move'
});
// 6. 加到组里
group.add(rect);
// 7. 组加到zrender实例里
zr.add(group);
});
任务名、指派人、任务耗时天数跟画日期类似,计算好位置宽高,直接加到组里即可,此处略过,最后的效果如下:
颜色有点单调,可以补足一下随机色生成:
js
function getRandomColor() {
// Generate random values for red, green, and blue components
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Convert values to hexadecimal and format the color
const hexColor = '#' + r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') +
b.toString(16).padStart(2, '0');
return hexColor;
}
❗️上述UI示例代码的码上掘金链接: code.juejin.cn/pen/7321236...
画交互
根据之前说到的整体思路流程图,我们已经完成了左半边初始化的动作,那交互是什么意思?这里的理解就是 f(参数,数据) = UI,通过某种用户操作,修改了参数/数据,再次重新渲染UI的过程。
哈,那甘特图demo中涉及的交互有哪些呢?我们来列一列:
浏览器:
- 窗口resize可以重新绘制画布
- 左右滚动重新绘制画布
任务条:
- 可左右扩展duration长度
- 可水平方向调整位置
- 可上下调整记录位置
- 双击可修改task任务信息
- 左右超出视口箭头显隐
- 点击超出视口箭头定位到taskBar
里程碑:
- hover日期点击可创建里程碑线
- 再次hover日期点击可删除已有
- hover日期出🚩
网格:
- hover可出虚线添加按钮
- 按钮点击添加任务
其他...
当然,我不会全部实现的代码列一遍,我就举几个典型的例子来说明一下。
hover日期出🚩
先画🚩:
啊,不规则图形,怎么画啊,前面也提到了其实可以根据svg的path来绘制图形。 🚩的svg,看这里 把path string传入zrender.path.createFromString
就能得到创建的元素了
js
function createFlag() {
const flag = zrender.path.createFromString('M3.333,10v 5 c0,0.184 -0.149, 0.333 -0.333, 0.333 h-0.667A0.333, 0.333 0 0 1 2, 15 V 1.333 c0, -0.368 0.298, -0.666 0.667, -0.666 h 11.525 a 0.667, 0.667 0 0 1 0.581, 0.994 L 12.76, 5.233 a 0.333, 0.333 0 0 0 0.002, 0.33 L 14.753, 9 A 0.667, 0.667 0 0 1 14.176, 10 H 3.333z', {
style: {
fill: '#F54A45'
}
})
return flag;
}
🚩周围还有一个圆圈,所以减一个flagGroup方便操作,上手隐藏自己。
js
function createFlagGroup(zr, gridX, halfUnitWidth, chartStartY, timeScaleHeight) {
const flagGroup = new zrender.Group()
const flag = createFlag()
const { width: fWidth, height: fHeight } = flag.getBoundingRect()
flag.attr({
x: gridX + halfUnitWidth - fWidth / 2,
y: (chartStartY - timeScaleHeight) + timeScaleHeight / 2 - fHeight / 2
})
const flagHoveredCircle = new zrender.Circle({
shape: {
cx: gridX + halfUnitWidth + 1,
cy: (chartStartY - timeScaleHeight) + timeScaleHeight / 2,
r: 12
},
style: {
fill: 'rgba(211, 211, 211, 0.5)'
}
})
flagGroup.add(flagHoveredCircle)
flagGroup.add(flag)
flagGroup.hide()
zr.add(flagGroup)
return [flagGroup, {
flag,
flagHoveredCircle
}]
}
遍历绘制dateText的地方画上🚩:
js
const [flagGroup, {
flag
}] = createFlagGroup(zr, gridX, halfUnitWidth, chartStartY, timeScaleHeight)
给日期文本绑定mouseover mouseleave事件,控制🚩和自己的显隐:
javascript
dateText.on('mouseover', function (e) {
this.attr({
style: {
opacity: 0
}
});
flagGroup.show()
})
dateText.on('mouseout', function (e) {
this.attr({
style: {
opacity: 1
}
});
flagGroup.hide()
});
完事,效果就下面这样:
点击🚩创建里程碑
- 日期绑定点击事件
- 点击后,修改mileStones数组
- 重新绘制调用redrawChart方法
初始化数据声明mileStones数组
js
const mileStones = [];
js
dateText.on('click', function() {
// 获取当前点击的日期格子的index
// prompt用户输入里程碑名
// 修改mileStones数组
// 重新绘制图表
});
先把之前的整个初始化绘制流程封装成方法redrawChart(clear = false) 设置一个clear参数,是否清空画布。 如果clear是true,则调用zr.clear()来清空画布。
js
function redrawChart(clear = false) {
// 清空画布
clear && zr.clear();
// 之前的初始化逻辑
// ...
}
根据mileStones数组绘制图形的逻辑就略过不写了,都很类似。
完事,最终效果:
窗口resize可以重新绘制画布
现状:
拖动窗口,画布不会重新绘制,右边空白
咋整?
大家可以先想想。
3
2
1
是的,聪明的你,一定想到了。
1、监听窗口resize
2、回调里重新redrawChart
妈蛋,那么简单,下一个!
js
window.addEventListener('resize', function() {
redrawChart(true);
});
咦。。。咋没效果。。。发生了神马???
是的=。=,一点点小意外,redrawChart前加个zr.resize();就行了,超纲了, 下一个,。 最后效果。
任务条 - 可水平方向调整位置
嗯嗯,开始了,任务条的交互!
拖动其实。。。已经可以了=。=,因为之前已经给group加了draggable: true
了,限制成水平拖动的话,参考zrender文档写上draggable: "horizontal"
就行了。
对,就这么简单。 然后这玩意儿不就监听drag事件,算算拖动了多少水平方向的距离,除个格子宽度?完事?
那走起,试试水。 设一个变量let dragStartX = 0;
记录一下拖动开始的位置。
监听开始拖事件:
js
group.on("dragstart", function (e) {
dragStartX = e.event.zrX;
});
监听拖动结束事件:(这里要注意的是offsetX,有超过格子一半则算整格,不到一半则不算整格的"四舍五入"逻辑)
js
group.on("dragend", function (e) {
const deltaX = e.event.zrX - dragStartX;
const dir = deltaX < 0 ? -1 : 1;
const delta = Math.abs(deltaX);
const mod = delta % unitWidth;
const offsetX = dir * (Math.floor(delta / unitWidth) + Math.floor(mod / halfUnitWidth));
task.start += offsetX;
if (offsetX) {
syncLocal();
}
// Redraw the chart after dragging
redrawChart(true);
对,完事,效果如图:
左右滚动重新绘制画布
最后讲一个!啊?为啥最后一个了啊?因为好多交互其实都是计算位置,画图形,绑定事件之类的工作,就不一一赘述了,有兴趣可以看github源码。(累了,写不动了。。。😭😭)
最后我挑了一个我觉得应该要有的交互,就是滚动!为了实用性,这东西总要能滚动吧,总不能盯着几天看吧。。。😭😭
还是先让大家自己想想,现状下怎么实现呢?
3
2
1
走起,滚动么肯定鼠标滚咯,所以自然想到监听mouseWheel事件 -> 拿到滚动的距离记下来 -> 重新画画布 (redrawChart(true, 滚动距离)),然后图表的起始位置计算考虑水平滚动距离好像就行了?
嗯,试试。
1、弄一个变量记录滚动位置,let lastScrollX = 0;
2、监听鼠标滚动
js
zr.on('mousewheel', function (e) {
e.event.preventDefault();
lastScrollX -= e.event.wheelDeltaX;
redrawChart(true, lastScrollX, 0);
})
3、redrawChart新增滚动距离,初始化位置(chartStartX、chartStartY
)计算考虑滚动距离
javascript
function redrawChart(clear, scrollX = lastScrollX, scrollY = 0) {
// ...
// margin left to the container
const chartStartX = initChartStartX - scrollX;
// margin top to the container
const chartStartY = Math.max(initChartStartY, timeScaleHeight + milestoneTopHeight) - scrollY;
// ...
}
4、之前画的timeScale的x位置也考虑滚动距离
js
const timeScale = new zrender.Rect({
shape: {
// ...
x: chartStartX + lastScrollX,
// ...
},
});
完事儿,美滋滋
emmmmm,滚的有点快,能不能慢一点?
当然可以!
加一个速度变量const scrollSpeed = 30;
,控制lastScrollX的改变速度就行了。(为啥是30?因为我试下来感觉30最好,当然你看示例demo里30其实是默认值,也是可以用url search上的参数修改,哈哈哈,能改的就别写死😄)
js
zr.on('mousewheel', function (e) {
// ...
e.event.preventDefault();
lastScrollX -= Math.floor(e.event.wheelDeltaX * 0.01 * scrollSpeed);
// ...
})
修改以后的效果:
❗️上述「交互示例代码」的码上掘金链接: code.juejin.cn/pen/7321272...
其他交互的实现思路:
可左右扩展duration长度
你demo链接上写上?debug=1,看一下就明白了,task左右画两个矩形,监听矩形的drag事件,然后算跨度,修改task.duration + task.start的值即可。
可上下调整记录位置
task的group,drag事件里算出上下拖动距离,然后画一根蓝线(增删),zr.refresh即可,dragend的时候算出目标位置,然后交互tasks里数组位置,redrawChart。
能支持大量数据的绘制 (比如10万条)
算出所有在视口里的tasks、mileStones、日期、网格等,然后在遍历的时候直接忽略掉不在视口里的不绘制,只画视口内的元素即可。
demo链接可以加上?mockTaskSize=100000&debug=1查看,控制台会打印绘制的task数量,比如点我
数据持久化
存本地: (useLocal) 所有修改数据的操作结束以后,写到localStorage,初始化的时候从localStorage读。
存远端: (useRemote) 所有修改数据的操作结束以后,调用接口写到远端数据库存下来,初始化的时候从数据库读数据然后更新本地的tasks和mileStones。
上面的两种:demo里都实现了,只是存远端的server端代码因为安全问题,我没放开域名,大家可以自行实现,就是一个查接口,一个写接口。本地可用。
其他
写这个demo我还做了这些:
- 数据持久化 (因为没经验数据库还被勒索了两次 😭😭😭)
- 工程上:迁到vite打包,自动化部署(到vercel、到github-pages)
后话
实际本人写本文的时候,需求已叫停。但个人就是觉得项目有点意思,好玩儿,做一半有点可惜,所以把这个demo做到了最后,并写了这篇文章。通过这个需求,我学到了不少知识,看了不少文章、代码,所以分享出来,希望对他人有所助益。
完事儿,跑路,看繁花去了~~~
参考资料:(感谢各位 🙇🙇)
- zrender文档⭐️⭐️⭐️
- canvas 性能优化原理 (视频)⭐️⭐️⭐️⭐️⭐️
- 浅谈 Canvas 渲染引擎⭐️⭐️⭐️⭐️⭐️
- 【Canvas系列】可视区域内渲染提高 Canvas 的书写性能⭐️⭐️⭐️⭐️⭐️
- 【Canvas 系列】通过上下分层优化 Canvas 书写性能
- 【Canvas系列】通过离屏渲染提高 Canvas 书写性能
- Infinite HTML canvas with zoom and pan
- cicada-flowchart ⭐️⭐️⭐️⭐️⭐️
- ng-gantt
- 如何绘制一个类甘特图 (附源码)