• 前端甘特图组件开发(一)


    背景
    • 工作中需要在网页上实现甘特图,以展示进度数据。通过网上调研相关项目,找到一款 dhtmlx-gantt 组件,在低程度上满足项目需求,但在部分定制功能(如时间轴自定义、编辑弹窗样式风格等)并不能完全满足项目需求。此外,使用此类开源项目,若遇到功能无法满足需求时,解决起来较为麻烦,基本只有在需求上进行妥协。
    • 个人在工作后暂时没有开发过相对复杂且功能较为完整的组件,开发甘特图组件既可以满足工作需要、方便开发人员,也可以加深自己对前端技术的理解。
      基于以上原因,开始着手开发一款甘特图组件 m-gantt,第一版首先以完成项目需求为目标,实现项目需要的功能,尽可能将配置项进行提取。后续将继续完善拓展应有功能,实现可配置化。
    开发准备
    • 本甘特图开发的基本思路源于这两篇文章:
      参考链接1
      参考链接2
    • 调研含 dhtmlx-gantt 在内的多款甘特图组件,了解甘特图组件所需要的基本功能以及数据的在展示方法、交互方法等。
    其他说明
    • 本甘特图组件目前仅支持 Angular 开发
    • 除 Angular 框架外,本组件无其他依赖包
    • 甘特图基于svg绘画,不依赖其他工具,可塑性强,且相较于使用标签加定位的布局方式,该方法代码量较少且逻辑清晰
    • 样式使用less语法
    开发内容概述

    基本思路

    1. 布局
      布局需要实现如下几项功能
      ① 主要分为左右两个部分,每个部分分上部固定区域和下部垂直滚动区域
      ② 左右部分的下部区域需要同时滚动
      ③ 右部需要横向滚动
      ④(可选)左侧部分支持缩放
    2. 表格区域
      ① 基本为常规表格,将表头固定在上部,表体放在下部
      ② 点击行数据可使进度图横向滚动到该项任务所在起始位置
    3. 时间轴区域
      ① 分多层,默认分为 年-月层、日层、自定义层
      ② 使用svg语法进行绘制
    4. 进度图区域
      ① 使用svg语法绘制
      ② 进度图根据实际数据实时渲染
      ③ 鼠标移动到单个任务进度条上显示数据详情
    布局

    请添加图片描述

    ① 主要分为左右两个部分,每个部分分上部固定区域(吸顶)和下部垂直滚动区域
    ② 左右部分的下部区域需要同时滚动(共用滚动条)
    ③ 右部需要横向滚动

    <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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    .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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
      @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);
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    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: 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;
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    
    <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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    进度图
    • 背景绘制
      ① 用 react 绘制格子
      ② 用 line 绘制横线
      ③ 用 path 绘制竖线
    // 数据
    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    <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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 进度图 bar 绘制
      请添加图片描述

    ① 用 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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    点击滚动

    点击任务滚动到任务开始位置
    请添加图片描述

    // 点击任务自动滚动
    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'
        })
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    显示详情

    鼠标移动到任务上显示任务详情
    请添加图片描述

    ① 创建一个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`);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    结构及样式代码略

    树形表格

    树形表格
    请添加图片描述

    ① 表格支持点击 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
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    以上,甘特图组件基本功能开发完成,后续工作:
    ① 完善其他实用功能
    ② 修改已有问题
    ③ 将数据、功能、样式封装为可配置项

    项目GitHub地址】⭐️

    原文地址
    个人博客】⭐️

    相关文章
    前端甘特图组件开发(二)

  • 相关阅读:
    详解JS遍历数组的十八种方法
    基于音频指纹的听歌识曲系统
    XJAR 混淆加密
    SpringBoot结合keytool配置ssl双向认证通信
    MixtralForCausalLM DeepSpeed Inference节约HOST内存【最新的方案】
    【湖科大教书匠】计算机网络随堂笔记第1章(计算机网络概述)
    BI设计下篇- 聚焦受众的视线
    龙芯S-2K2000板卡测试记录,安装loongnix系统已知问题及DPDK
    KUKA机器人KRC5控制柜面板LED显示
    第十章_祖冲之_圆周率
  • 原文地址:https://blog.csdn.net/PorkCanteen/article/details/128075399