从零构建Angular甘特图组件:SVG渲染与交互设计实战
1. 为什么需要从零开发Angular甘特图组件?
在项目管理工具中,甘特图就像项目进度的"X光片",能直观展示任务时间轴、依赖关系和完成状态。市面上虽然有不少现成的甘特图库,比如dhtmlx-gantt、NgxGantt等,但我在实际项目中发现三个痛点:
第一是定制化困境。现有组件往往对时间轴样式、任务条交互有严格限制。比如需要将工作日历调整为"6天工作制",或者要给任务条添加特殊标记时,修改源码就像在别人的代码迷宫里找出口。
第二是性能瓶颈。当渲染超过500个任务项时,某些基于DOM的库会出现明显卡顿。去年我们有个智慧园区项目,就因为第三方甘特图在IE11上的渲染速度慢了3倍,不得不连夜重写。
第三是技术债风险。曾经有个项目因为依赖的甘特图库停止维护,导致整个系统无法升级Angular版本。自己掌控核心代码,才能避免被"卡脖子"。
2. 项目结构与基础搭建
2.1 初始化Angular工程
推荐使用Angular CLI创建工程骨架:
ng new gantt-component --style=less --routing=false cd gantt-component ng generate component gantt关键依赖说明:
- @angular/cdk/drag-drop:用于实现任务条拖拽
- date-fns:比moment更轻量的日期库
- rxjs:处理异步事件流
在gantt.component.ts中定义核心接口:
interface GanttTask { id: string; name: string; start: Date; end: Date; progress: number; dependencies?: string[]; // 扩展字段 [key: string]: any; } interface GanttConfig { startDate: Date; endDate: Date; viewMode: 'day' | 'week' | 'month'; }2.2 布局架构设计
采用经典的"左侧表格+右侧图表"双栏布局,通过CSS Grid实现响应式:
.gantt-container { display: grid; grid-template-columns: 300px 1fr; height: 100vh; .gantt-side { border-right: 1px solid #e8e8e8; overflow-y: auto; } .gantt-main { position: relative; overflow: auto; .gantt-header { position: sticky; top: 0; z-index: 10; background: white; } } }3. SVG渲染核心技术实现
3.1 动态时间轴计算
时间轴需要智能适应不同时间跨度。我封装了一个TimeScaleService来处理日期分段:
generateTimeScale(start: Date, end: Date) { const days = differenceInDays(end, start); return { // 年-月刻度 years: eachMonthOfInterval({ start, end }).map(date => ({ x: differenceInDays(date, start) * this.dayWidth, label: format(date, 'yyyy-MM') })), // 周刻度 weeks: eachWeekOfInterval({ start, end }).map(date => ({ x: differenceInDays(date, start) * this.dayWidth, label: `W${getWeek(date)}` })), // 日刻度 days: Array.from({ length: days }, (_, i) => { const current = addDays(start, i); return { x: i * this.dayWidth, label: format(current, 'dd'), isWeekend: [0, 6].includes(getDay(current)) }; }) }; }3.2 任务条绘制技巧
使用SVG的<rect>和<path>组合实现带圆角和进度指示的任务条:
<svg [attr.width]="totalWidth" [attr.height]="totalHeight"> <g *ngFor="let task of tasks"> <!-- 背景条 --> <rect [attr.x]="getTaskX(task)" [attr.y]="getTaskY(task)" [attr.width]="getDurationWidth(task)" [attr.height]="barHeight" rx="3" ry="3" fill="#e1e5ee"/> <!-- 进度条 --> <rect [attr.x]="getTaskX(task)" [attr.y]="getTaskY(task)" [attr.width]="getDurationWidth(task) * task.progress" [attr.height]="barHeight" rx="3" ry="3" fill="#4a7eff"/> <!-- 依赖关系线 --> <path *ngIf="task.dependencies" [attr.d]="getDependencyPath(task)" stroke="#999" stroke-dasharray="3,2"/> </g> </svg>4. 交互设计实战技巧
4.1 拖拽功能实现
结合Angular CDK实现三种拖拽场景:
// 水平拖拽调整时间 handleBarDrag(event: CdkDragMove) { const newDays = Math.round(event.pointerPosition.x / this.dayWidth); this.updateTaskDate(this.activeTask, newDays); } // 垂直拖拽改变任务层级 handleRowDrop(event: CdkDragDrop<any>) { moveItemInArray(this.tasks, event.previousIndex, event.currentIndex); } // 右侧调整任务时长 handleResize(event: ResizeEvent) { this.activeTask.duration = Math.max(1, Math.round(event.rectangle.width / this.dayWidth)); }4.2 智能滚动策略
当拖拽任务条接近视图边界时,自动平滑滚动:
private setupAutoScroll() { fromEvent(this.container.nativeElement, 'mousemove') .pipe( throttleTime(100), filter(() => this.isDragging), map((e: MouseEvent) => ({ x: e.clientX - this.containerRect.left, y: e.clientY - this.containerRect.top })) ) .subscribe(pos => { const { width, height } = this.containerRect; if (pos.x > width - 50) { this.scrollContainer('right'); } else if (pos.x < 50) { this.scrollContainer('left'); } if (pos.y > height - 50) { this.scrollContainer('down'); } else if (pos.y < 50) { this.scrollContainer('up'); } }); }5. 性能优化关键点
5.1 虚拟滚动方案
当任务数量超过500时,采用动态渲染策略:
get visibleTasks() { const scrollTop = this.scrollContainer.scrollTop; const startIdx = Math.floor(scrollTop / this.rowHeight); const endIdx = startIdx + Math.ceil(this.viewportHeight / this.rowHeight); return this.tasks.slice(startIdx, endIdx).map((task, i) => ({ ...task, $offset: (startIdx + i) * this.rowHeight })); }5.2 SVG渲染优化
通过这些技巧提升SVG性能:
- 使用
<defs>定义复用元素 - 对静态元素设置
shape-rendering="crispEdges" - 批量DOM操作前先
detachSVG元素 - 复杂路径使用
pathLength优化
<svg> <defs> <linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" stop-color="#4a7eff"/> <stop offset="100%" stop-color="#6a5acd"/> </linearGradient> </defs> <g *ngFor="let task of visibleTasks" [attr.transform]="'translate(0,' + task.$offset + ')'"> <!-- 复用渐变定义 --> <rect fill="url(#progressGradient)" ... /> </g> </svg>6. 企业级功能扩展
6.1 多时区支持
通过Intl.DateTimeFormat实现时间显示自适应:
formatTime(date: Date, timezone: string) { return new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour12: false, year: 'numeric', month: 'short', day: 'numeric' }).format(date); }6.2 导出PDF功能
结合html2canvas和jspdf实现:
async exportPDF() { const canvas = await html2canvas(this.ganttElement); const pdf = new jsPDF('landscape'); const imgData = canvas.toDataURL('image/png'); pdf.addImage(imgData, 'PNG', 10, 10, 280, 150); pdf.save('gantt-export.pdf'); }7. 踩坑与解决方案
日期计算陷阱:发现直接使用new Date()进行日期加减会忽略夏令时,改用date-fns的addDays等函数。
SVG文本换行:SVG的<text>不支持自动换行,最终采用foreignObject嵌入HTML实现:
<foreignObject x="10" y="20" width="100" height="40"> <div xmlns="http://www.w3.org/1999/xhtml" class="task-label"> {{task.name}} </div> </foreignObject>拖拽性能问题:在拖拽过程中改用transform代替直接修改x/y属性,性能提升300%。
开发过程中最耗时的部分是处理时间轴的动态缩放逻辑,需要同时考虑:
- 不同时间单位(天/周/月)的刻度密度
- 节假日/工作日的视觉区分
- 缩放时的动画平滑过渡
最终方案是通过requestAnimationFrame实现渐进式渲染,并建立刻度生成规则:
const zoomLevels = { day: { unit: 'day', step: 1, format: 'dd', subStep: 4 }, week: { unit: 'week', step: 1, format: "'W'ww", subStep: 7 }, month: { unit: 'month', step: 1, format: 'MMM yyyy', subStep: 4 } };