发布时间:2023-04-19 11:00
基本思路
① 主要分为左右两个部分,每个部分分上部固定区域(吸顶)和下部垂直滚动区域
② 左右部分的下部区域需要同时滚动(共用滚动条)
③ 右部需要横向滚动
<div class="gantt-table" #table>
<div class="header"></div>
<div class="body"></div>
</div>
<div class="gantt-chart" #chart>
<div class="header"></div>
<div class="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);
}
private scrollChart = (e: any) => {
// 当右侧进度图没有滚动时,使之随表格滚动
if (!this.scrollLock.isChartScroll) {
this.scrollLock.isTableScroll = true;
this.chart.nativeElement.scroll({
top: e.target?.scrollTop
})
}
this.scrollLock.isTableScroll = false;
}
private scrollTable = (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图知识可以参考另一篇文章【svg学习】
① 计算时间轴的长度
② 构造时间数组
③ 通过位置绘制时间轴
// 时间轴
public dateConfig: any = {
startDate: new Date('2077-12-31'),
endDate: new Date('1999-1-1'),
total: 0, // 总天数
svgWidth: 0, // 整体宽度
svgHeight: 60, // 时间轴高度
dateList: [], // 日轴
monthList: [] // 月轴
}
// 配置时间轴数据
private setGanttData(): void {
// 遍历任务数据 获取最大/最小值
this.ganttConfig.data.forEach((task: any) => {
const { startDate, endDate } = task;
if (startDate && new Date(startDate) < this.dateConfig.startDate) {
this.dateConfig.startDate = new Date(startDate)
}
if (endDate && new Date(endDate) > this.dateConfig.endDate) {
this.dateConfig.endDate = new Date(endDate);
}
})
// 前后加N天保证显示效果
this.dateConfig.endDate = new Date(this.dateConfig.endDate.getTime() + 3 * 24 * 60 * 60 * 1000);
this.dateConfig.startDate = new Date(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(new Date(this.dateConfig.startDate.getTime() + i * 24 * 60 * 60 * 1000), 'dd'),
day: week[new Date(this.dateConfig.startDate.getTime() + i * 24 * 60 * 60 * 1000).getDay()],
month: this.datePipe.transform(new Date(this.dateConfig.startDate.getTime() + i * 24 * 60 * 60 * 1000), 'yyyy-MM'),
})
}
// 月
const monthMap = new Map();
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;
})
}
<!-- 时间轴 -->
<div class="header" [style.width]="dateConfig.svgWidth + 'px'">
<!-- 月数据 -->
<svg [attr.width]="dateConfig.svgWidth" [attr.height]="timeLineHeight">
<g class="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>
<line x1="0" y1="30" [attr.x2]="dateConfig.svgWidth" y2="30" stroke="#d9dde0" />
</g>
</svg>
<!-- 日数据 -->
<svg [attr.width]="dateConfig.svgWidth" [attr.height]="timeLineHeight">
<g class="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>
// 数据
public ganttConfig: any = {
columns: columns,
data: data,
chartData: []
}
// 数据预处理
private preprocessData(data: Array<any>): Array<any> {
data.forEach(row => {
const startDay = (new Date(row.startDate).getTime() - this.dateConfig.startDate.getTime()) / (24 * 60 * 60 * 1000);
row.startDay = startDay;
})
return data;
}
<div class="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>
① 用 rect 绘制每项任务的总计划 bar
② 用 rect 绘制每项任务的已完成 bar
③ 用 text 填充文字
<g class="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>
点击任务滚动到任务开始位置
// 点击任务自动滚动
public scrollToBar(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: ''
}
public showDetail(row: any, flag = false): void {
if (flag) {
this.showModal = true;
// 绑定数据
// ...
document.addEventListener('mousemove', this.moveModal)
} else {
this.showModal = false
}
}
private moveModal = (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 (控制是否显示)
// 表格展开
public showSubData(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地址】⭐️
原文地址
【个人博客】⭐️
相关文章
【前端甘特图组件开发(二)】