前言
上一篇介绍了如何实现布局,本篇就具体来聊聊如何使用svg实现甘特图,由于篇幅过长,我将把如何改造antd tree为antd table放在另一篇文章~
what is svg
正式开始前,先放个svg介绍: svg 文档
可缩放矢量图形(Scalable Vector Graphics,SVG),是一种用于描述二维的矢量图形,基于 XML 的标记语言。作为一个基于文本的开放网络标准,SVG能够优雅而简洁地渲染不同大小的图形,并和CSS,DOM,JavaScript和SMIL等其他网络标准无缝衔接。本质上,SVG 相对于图像,就好比 HTML 相对于文本。
现实前端开发中,svg常常是作为页面中的小icon存在,在移动开发中,还会作为一张背景图,主要是为了性能优化,减少网络请求。
而这次的gantt开发,不仅用到了svg
,还用到了其他一些svg
的常用标签
rect 钜形标签
专有属性
x
定义矩形左侧位置y
定义矩形顶端位置width
定义矩形宽度height
定义矩形高度rx
定义矩形x轴半径ry (en-US)
定义矩形y轴半径
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="120" width="60" height="60" ry="0" rx="15"/>
<rect x="120" y="120" width="60" height="60" ry="15" rx="15"/>
<rect x="220" y="120" width="60" height="60" ry="150" rx="15"/>
</svg>
rect
标签在gantt图中主要用于绘制 任务小块bar,还有背景条
path 路径标签
path元素是用来定义形状的通用元素。所有的基本形状都可以用path元素来创建。
用path来绘制图形相对比较复杂,这里建议直接使用svg编辑器绘制,目前许多绘图软件都支持直接导出svg格式的图片,而在gantt图中,主要用于绘制竖线,用来分割时间,所以这里简单介绍一下如何利用path绘制直线。
下面的命令可用于路径数据(可以使用大小写,大写为绝对位置,小写为相对位置):
M
= move to 传入目标点的x轴位置、y轴位置 => M2 0H
= horizontal lineto 绘制平行线V
= vertical lineto 绘制垂直直线
// 距离左侧100 绘制了一个长度为100的直线,直线宽度为3
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
<path d="M100 0 V 100" stroke="orange" stoke-width="3" />
</svg>
line 标签
line元素是一个SVG基本形状,用来创建一条连接两个点的线。
x1
= 第一个点的x 轴位置y1
= 第一个点的y 轴位置x2
= 第二个点的x 轴位置y2
= 第二个点的y 轴位置
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="20" x1="0" x1="20" y1="20" stroke="orange" stoke-width="3" />
</svg>
text 文本标签
text
元素定义了一个由文字组成的图形。可以将渐变、图案、剪切路径、遮罩或者滤镜应用到text上。在gantt中用来展示时间。
// 在 x 为10 y 为10的位置绘制文字 october
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
<text x="10" y="10">October</text>
</svg>
g 组合标签
g
是用来组合对象的容器。添加到g
元素上的变换会应用到其所有的子元素上。添加到g
元素的属性会被其所有的子元素继承。在gantt图中,用于组合对象。
<svg width="100%" height="100%" viewBox="0 0 95 50" xmlns="http://www.w3.org/2000/svg">
<g fill="orange" stroke-width="5">
<rect x="20" y="120" width="60" height="60" ry="0" rx="15"/>
<rect x="120" y="120" width="60" height="60" ry="15" rx="15"/>
<rect x="220" y="120" width="60" height="60" ry="150" rx="15"/>
</g>
</svg>
Gantt 组件分析
介绍完需要用到的标签,就可以正式开始绘制gantt啦,先看看效果:
由图像可将Gantt分为以下几个大块:
- 时间组件
- 背景组件
- Bar组件
gantt 还支持缩放(目前仅支持视觉上的缩放,不涉及季度或星期,年份的切换),通过系数confficient
来维护gantt的缩放比例,1为最大,0.6为最小~
const coefficientList = [1, 0.8, 0.6]
Gantt 绘制
数据处理(时间组件)
数据是gantt最重要的部分,读取数据,得到甘特图的绘制时间范围以及每个任务bar的位置。而这一个任务则交给gantt的头部时间展示处理。
需求分析:
- 需要知道时间范围
- 需要知道当前
columnwidth
时间宽度 = basicColumnwidth * coefficient
第一次遍历
获取列表中的最早时间和最晚时间
const setupGanttDates = () => {
// 初始化为当前时间
const iniDate = {
ganttStart: moment().startOf('d').toDate(),
ganttEnd: moment().endOf('d').toDate(),
};
tasks.forEach(task => {
const {startDate, endDate} = task;
// 判断改startDate是否小于上一个最小startDate,是的话时间交换
if (startDate && startDate < tempDate.ganttStart) {
tempDate.ganttStart = task.startDate;
}
// 判断改endDate时间是否小于上一个最大endDate时间,是的话时间交换
if (endDate && endDate > tempDate.ganttEnd) {
tempDate.ganttEnd = task.endDate;
}
});
// 如果开始时间和结束时间相差小于20天,开始和结束时间分别要减少和增加一个月的时间
// 保证页面样式可以展示时间刻度
if (moment(tempDate.ganttEnd).diff(moment(tempDate.ganttStart), 'd') < 20) {
dateRange.current = {
ganttStart: moment(tempDate.ganttStart).subtract(1, 'month').toDate(),
ganttEnd: moment(tempDate.ganttEnd).add(1, 'month').toDate(),
};
} else {
// 为便于查看,开始和结束时间也要分别减少增加7天,给前后留白
dateRange.current = {
ganttStart: moment(tempDate.ganttStart).subtract(1, 'week').toDate(),
ganttEnd: moment(tempDate.ganttEnd).add(1, 'week').toDate(),
};
}
};
绘制时间
当前gantt的总天数为ganttEnd - ganttStart
知道了总共需要的绘制天数,也知道时间的宽度,便可以绘制时间组件了
// svgWidth(时间组件宽度) = 总天数 * columWidth
// svgHeight(时间组件高度) = 默认高度
// x(文字x轴位置) = 天数 * columWith
// y默认高度) = 默认时间高度
<svg width={svgWidth} height={svgHeight}>
<g className="date">
<text x={x} y={y} >{日期}</text>
...
</g>
</svg>
背景组件
背景的绘制就比较简单啦,结合前面说到的,用rect
标签和path
绘制, 分别遍历ganttData和date
// svgWidth (背景宽度) = 总天数 * columWidth
// svgHeight(背景高度) = data.length(当前任务数) * rowHeight(默认高度)
// x = 天数 * columWith
// rowY(Y轴) = dataIndex * barHeight
// rowWidth = svgWidth
// 此处模拟过程,不符合jsx语法
<svg width={svgWidth} height={svgHeight}>
// 绘制横向元素
data.forEach(() => {
<line x1={0} y1={rowY+ rowHeight} x2={rowWidth} y2={rowY+ rowHeight} />
<rect x={0} y={rowY} width={svgWidth} height={rowHeight} />
// bar的高度 + bar上下padding
rowY += barHeight + barPadding;
});
// 绘制纵向元素
dates.forEach(item => {
let tickClass = 'tick';
// new Date().getDate() => 判断是不是本月的1号
if (item.getDate() === 1) {
// 假如是1号需要加粗
tickClass += ' thick';
}
<path d: `M ${x} ${0} v ${svgHeight}` classname={tickClass} />
x += columWith;
});
</svg>
绘制Bar
除了绘制bar的位置及大小,还需要实现点击展示详情,这里用了antd的 Popover组件
// width = task天数 * columnWidth
// height = barHeight
// x = 天数 * columWith
// y = dataIndex * rowHeight
// color = 默认颜色
// cornerRadius = 默认角度
<g>
<Popover content={content} title={title} placement='bottomRight'>
<rect
x={x}
y={y}
width={width}
height={height}
rx={cornerRadius}
ry={cornerRadius}
fill={color}
/>
</Popover>
...
</g>
Gantt交互优化
自动定位
需求分析:
用户左侧点击task, 右侧bar
滑到最左侧
情况一(橙色为目标小块)
已滚动距离为0
- x1 =
tableDom.getBoundingClientRect().left
- x2 =
selectBar.getBoundingClientRect().right
实际需要滚动的距离为: diff = X2 - X1
rightRef.current.scrollTo({
left: diff,
behavior: 'smooth',
});
情况二(橙色为目标小块)
已经滚动了x1
- x1 =
rightRef.current.scrollLeft
- x2 =
tableDom.getBoundingClientRect().left
- x3 =
selectBar.getBoundingClientRect().right
实际需要滚动的距离为:diff = X3 - X2 + X1
const handleSekect = (selectedKeys: Key[]) => {
const id = selectedKeys && (selectedKeys[0] as string)?.split('_')[0];
// 获取目标小块
const targetBar = document.querySelector(`g[data-id="${id}"]`);
// 左侧table
const tableRef = (ref as React.RefObject<HTMLElement>).current;
if (targetBar && tableRef) {
// 目标小块左侧距离client距离
const x = targetBar.getBoundingClientRect().left;
// table右侧距离client距离
const parentX = tableRef.getBoundingClientRect().right;
// 上次gantt滚动的距离
const preScroll = props.rightRef.current?.scrollLeft || 0;
const diff = x - parentX;
props.rightRef.current?.scrollTo({
left: preScroll + diff,
behavior: 'smooth',
});
}
};
Gantt 缩放
需求分析:
gantt随着用户点击缩放进行缩放,且视图与缩放前一致
注意: 缩放前后的滚动距离比例实际与gantt伸缩比例一致
const handleClick = (type: ClickType) => {
// 防止用户多次点击
if (!delay) {
setDelay(true);
const ganttRef = (ref as React.RefObject<HTMLDivElement>).current;
const preScroll = ganttRef?.scrollLeft || 0;
const oldIndex = coefficient;
const newIndex = type === ClickType.Add ? oldIndex - 1 : oldIndex + 1;
const newScroll =
(preScroll * coefficientList[newIndex]) /
coefficientList[coefficient];
setCoefficient(newIndex);
// 等待svg绘制结束
setTimeout(() => {
if (newScroll) {
ganttRef?.scrollTo({
left: newScroll,
});
}
setDelay(false);
}, 800);
}
};
总结
以上就是整个gantt绘制的逻辑。文章主要侧重描述逻辑,没有放太多代码,源码在GitHub
由于团队的需求仅仅是展示数据,而且数据量非常大,为了不那么笨重,使用了svg
,没有使用div
。假如希望实现gantt拖拽的效果,大家可以也可以使用div
的~