背景
- 工作中需要在网页上实现甘特图,以展示进度数据。通过网上调研相关项目,找到一款 dhtmlx-gantt 组件,在低程度上满足项目需求,但在部分定制功能(如时间轴自定义、编辑弹窗样式风格等)并不能完全满足项目需求。此外,使用此类开源项目,若遇到功能无法满足需求时,解决起来较为麻烦,基本只有在需求上进行妥协。
- 个人在工作后暂时没有开发过相对复杂且功能较为完整的组件,开发甘特图组件既可以满足工作需要、方便开发人员,也可以加深自己对前端技术的理解。 基于以上原因,开始着手开发一款甘特图组件 m-gantt,第一版首先以完成项目需求为目标,实现项目需要的功能,尽可能将配置项进行提取。后续将继续完善拓展应有功能,实现可配置化。
开发准备
- 本甘特图开发的基本思路源于这两篇文章: 【参考链接1】 【参考链接2】
- 调研含 dhtmlx-gantt 在内的多款甘特图组件,了解甘特图组件所需要的基本功能以及数据的在展示方法、交互方法等。
其他说明
- 本甘特图组件目前仅支持 Angular 开发
- 除 Angular 框架外,本组件无其他依赖包
- 甘特图基于svg绘画,不依赖其他工具,可塑性强,且相较于使用标签加定位的布局方式,该方法代码量较少且逻辑清晰
- 样式使用less语法
开发内容概述
基本思路
- 布局 布局需要实现如下几项功能 ① 主要分为左右两个部分,每个部分分上部固定区域和下部垂直滚动区域 ② 左右部分的下部区域需要同时滚动 ③ 右部需要横向滚动 ④(可选)左侧部分支持缩放
- 表格区域 ① 基本为常规表格,将表头固定在上部,表体放在下部 ② 点击行数据可使进度图横向滚动到该项任务所在起始位置
- 时间轴区域 ① 分多层,默认分为 年-月层、日层、自定义层 ② 使用svg语法进行绘制
- 进度图区域 ① 使用svg语法绘制 ② 进度图根据实际数据实时渲染 ③ 鼠标移动到单个任务进度条上显示数据详情
布局
① 主要分为左右两个部分,每个部分分上部固定区域(吸顶)和下部垂直滚动区域
② 左右部分的下部区域需要同时滚动(共用滚动条)
③ 右部需要横向滚动
<divclass="gantt-table"#table><divclass="header"></div><divclass="body"></div></div><divclass="gantt-chart"#chart><divclass="header"></div><divclass="body"></div></div>
.gantt-container {
height: 800px;
display: flex; // 使用flex布局
overflow: hidden;
.gantt-table, .gantt-chart {
.header {
position: sticky;
height: @headHeight;
top: 0;
}
.body {
height: 900px;
}
}
// 左侧表格
.gantt-table {
position: relative;
overflow-x: hidden;
overflow-y: scroll;
}
// 隐藏左侧滚动条
.gantt-table::-webkit-scrollbar {
width: 0;
}
// 右侧进度图
.gantt-chart {
overflow-x: scroll;
flex: 1;
}
}
@ViewChild('table') table:any;@ViewChild('chart') chart:any;public scrollLock ={
isTableScroll:false,
isChartScroll:false}ngAfterViewInit():void{// 监听左侧表格this.table.nativeElement.addEventListener('scroll',this.scrollChart);// 监听右侧表格this.chart.nativeElement.addEventListener('scroll',this.scrollTable);}privatescrollChart=(e:any)=>{// 当右侧进度图没有滚动时,使之随表格滚动if(!this.scrollLock.isChartScroll){this.scrollLock.isTableScroll =true;this.chart.nativeElement.scroll({
top: e.target?.scrollTop
})}this.scrollLock.isTableScroll =false;}privatescrollTable=(e:any)=>{// 当左侧表格没有滚动时,使之随进度图滚动if(!this.scrollLock.isTableScroll){this.scrollLock.isChartScroll =true;this.table.nativeElement.scroll({
top: e.target?.scrollTop
})}this.scrollLock.isChartScroll =false;}ngOnDestroy():void{this.table.nativeElement.removeEventListener('scroll',this.scrollChart);this.chart.nativeElement.removeEventListener('scroll',this.scrollTable);}
SVG
本甘特图使用svg语法绘制,主要用到以下几种常用标签
- react 矩形标签 - x: 左侧距离- y: 顶部距离- width: 宽度- height: 高度- rx: x轴半径- rx: y轴半径
- path 路径标签(eg: M 100 0 V 100) - M: move to 传入目标点的坐标 x y- H: horizontal lineto 平行线- V: vertical lineto 垂直线
- line 线标签 - x1 y1: 第一个点的坐标- x2 y2: 第二个点的坐标
- text 文本标签
- g 组合标签 - 添加到g上的变化会应用到其子元素
更加详细的SVG图知识可以参考另一篇文章【svg学习】
时间轴
① 计算时间轴的长度
② 构造时间数组
③ 通过位置绘制时间轴
// 时间轴public dateConfig:any={
startDate:newDate('2077-12-31'),
endDate:newDate('1999-1-1'),
total:0,// 总天数
svgWidth:0,// 整体宽度
svgHeight:60,// 时间轴高度
dateList:[],// 日轴
monthList:[]// 月轴}// 配置时间轴数据privatesetGanttData():void{// 遍历任务数据 获取最大/最小值this.ganttConfig.data.forEach((task:any)=>{const{ startDate, endDate }= task;if(startDate &&newDate(startDate)<this.dateConfig.startDate){this.dateConfig.startDate =newDate(startDate)}if(endDate &&newDate(endDate)>this.dateConfig.endDate){this.dateConfig.endDate =newDate(endDate);}})// 前后加N天保证显示效果this.dateConfig.endDate =newDate(this.dateConfig.endDate.getTime()+3*24*60*60*1000);this.dateConfig.startDate =newDate(this.dateConfig.startDate.getTime()-3*24*60*60*1000);this.dateConfig.total =(this.dateConfig.endDate.getTime()-this.dateConfig.startDate.getTime())/(24*60*60*1000);// 计算总宽度this.dateConfig.svgWidth =this.dateConfig.total *this.squareWidth;// 时间轴// 日const week =['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];for(let i =0; i <this.dateConfig.total; i++){this.dateConfig.dateList.push({
text:this.datePipe.transform(newDate(this.dateConfig.startDate.getTime()+ i *24*60*60*1000),'dd'),
day: week[newDate(this.dateConfig.startDate.getTime()+ i *24*60*60*1000).getDay()],
month:this.datePipe.transform(newDate(this.dateConfig.startDate.getTime()+ i *24*60*60*1000),'yyyy-MM'),})}// 月const monthMap =newMap();this.dateConfig.dateList.forEach((date:any)=>{const month = date.month;if(monthMap.has(month)){
monthMap.set(month, monthMap.get(month)+1)}else{
monthMap.set(month,1)}})let lengthBefore:number=0;
monthMap.forEach((value, key)=>{this.dateConfig.monthList.push({
text: key,
left: lengthBefore
})
lengthBefore += value;})}
<!-- 时间轴 --><divclass="header"[style.width]="dateConfig.svgWidth + 'px'"><!-- 月数据 --><svg[attr.width]="dateConfig.svgWidth"[attr.height]="timeLineHeight"><gclass="date"*ngFor="let month of dateConfig.monthList; let i = index;"><!-- 文字 --><text[attr.x]="month.left * squareWidth + 5"[attr.y]="timeLineHeight / 2 + 4"style="font-size: 12px;">{{month.text}}</text><!-- 时间轴边框 --><path[attr.d]="'M ' + month.left * squareWidth + ' 0 V 30'"stroke="#d9dde0"></path><linex1="0"y1="30"[attr.x2]="dateConfig.svgWidth"y2="30"stroke="#d9dde0"/></g></svg><!-- 日数据 --><svg[attr.width]="dateConfig.svgWidth"[attr.height]="timeLineHeight"><gclass="date"*ngFor="let date of dateConfig.dateList; let i = index;"><text[attr.x]="i * squareWidth + 5"[attr.y]="timeLineHeight / 2 + 4"style="font-size: 12px;">{{date.text}}</text><text[attr.x]="i * squareWidth + 20"[attr.y]="timeLineHeight / 2 + 4"style="font-size: 8px;">{{date.day}}</text><path[attr.d]="'M ' + i * squareWidth + ' 0 V 30'"stroke="#d9dde0"></path></g></svg></div>
进度图
- 背景绘制 ① 用 react 绘制格子 ② 用 line 绘制横线 ③ 用 path 绘制竖线
// 数据public ganttConfig:any={
columns: columns,
data: data,
chartData:[]}// 数据预处理privatepreprocessData(data:Array<any>):Array<any>{
data.forEach(row =>{const startDay =(newDate(row.startDate).getTime()-this.dateConfig.startDate.getTime())/(24*60*60*1000);
row.startDay = startDay;})return data;}
<divclass="body"><svg[attr.width]="dateConfig.svgWidth"[attr.height]="ganttConfig.chartData.length * lineHeight"><rect*ngFor="let row of ganttConfig.chartData; let i = index;"x="0"[attr.y]="lineHeight * i"[attr.width]="dateConfig.svgWidth"[attr.heigth]="lineHeight"[attr.fill]="i % 2 === 0 ? '#fff' : '#f9fafb'"></rect><path*ngFor="let date of dateConfig.dateList; let i = index;"[attr.d]="'M ' + i * squareWidth + ' 0 V ' + ganttConfig.chartData.length * lineHeight"stoke="#d9dde0"></path><line*ngFor="let row of ganttConfig.chartData; let i = index;"x1="0"[attr.y1]="lineHeight * i + lineHeight"[attr.x2]="dateConfig.svgWidth"[attr.y2]="lineHeight * i + lineHeight"stroke="#d9dde0"/><!-- 进度图 --></svg></div>
- 进度图 bar 绘制
① 用 rect 绘制每项任务的总计划 bar
② 用 rect 绘制每项任务的已完成 bar
③ 用 text 填充文字
<gclass="bar"*ngFor="let row of ganttConfig.chartData; let i = index;"(mouseenter)="showDetail(row, true)"(mouseleave)="showDetail(row)"><!-- 全部 --><rect[id]="'bar_' + i"[attr.x]="row.startDay * squareWidth"[attr.y]="i * lineHeight + (lineHeight - barHeight) / 2"[attr.width]="row.duration * squareWidth"[attr.height]="barHeight"[attr.rx]="barHeight / 2"[attr.ry]="barHeight / 2"[attr.fill]="row.parentId ? subBarColor : barColor"></rect><!-- 进度 --><rect[attr.x]="row.startDay * squareWidth"[attr.y]="i * lineHeight + (lineHeight - barHeight) / 2"[attr.width]="(row.duration * squareWidth) * row.progress"[attr.height]="barHeight"[attr.rx]="barHeight / 2"[attr.ry]="barHeight / 2"[attr.fill]="row.parentId ? subProgressBarColor : progressBarColor"></rect><text[attr.x]="row.startDay * squareWidth + 20"[attr.y]="(i + 0.5) * lineHeight + 5"[attr.fill]="barFontColor"style="font-size: 12px;">{{row.name}}</text></g>
点击滚动
点击任务滚动到任务开始位置
// 点击任务自动滚动publicscrollToBar(row:any):void{const targetBar = document.querySelector(`#bar_${this.ganttConfig.chartData.indexOf(row)}`);if(targetBar &&this.table){// 目标进度条左侧与client距离const x = targetBar.getBoundingClientRect().left;// table右侧与client距离const parentX =this.table.nativeElement.getBoundingClientRect().right;const preScroll =this.chart.nativeElement.scrollLeft ||0;const diff = x - parentX;// 滚动this.chart.nativeElement.scrollTo({
left: preScroll + diff,
behavior:'smooth'})}}
显示详情
鼠标移动到任务上显示任务详情
① 创建一个modal标签,设置基本样式,在里面放置需要展示的详情
② 通过监听鼠标移动事件,将鼠标的位置传递给该元素,实现跟随鼠标移动
③ 在鼠标进入 bar 时绑定,在鼠标移出 bar 时解绑
// 弹窗显示详情@ViewChild('msgModal') msgModal:any;public showModal:boolean=false;public modalData:any={
name:'任务1',
startDate:'2022-10-1',
status:'进行中',
progress:''}publicshowDetail(row:any, flag =false):void{if(flag){this.showModal =true;// 绑定数据// ...
document.addEventListener('mousemove',this.moveModal)}else{this.showModal =false}}privatemoveModal=(e:any)=>{
document.querySelector('#msg-modal')?.setAttribute('style',`top: ${e.clientY}px; left: ${e.clientX -510}px`);}
结构及样式代码略
树形表格
树形表格
① 表格支持点击 icon 展开与折叠
② 进度图的对应项根据表格的折叠与否决定是否显示
③ 为了支持父子级关系及控制显示,任务数据需添加以下字段:
a: id
b: parentId (仅子级数据需要,关联父子关系)
c: open (仅父级数据需要,控制是否展开状态,变换icon)
d: show (控制是否显示)
// 表格展开publicshowSubData(id:string):void{this.ganttConfig.data.forEach((item:any)=>{if(item.id === id){
item.open =!item.open;}if(item.parentId === id){
item.show =!item.show;}})this.ganttConfig.chartData =this.ganttConfig.data.filter((row:any)=>{return row.show ===true})}
以上,甘特图组件基本功能开发完成,后续工作:
① 完善其他实用功能
② 修改已有问题
③ 将数据、功能、样式封装为可配置项
【项目GitHub地址】⭐️
原文地址
【个人博客】⭐️
相关文章
【前端甘特图组件开发(二)】
版权归原作者 PorkCanteen 所有, 如有侵权,请联系我们删除。