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

news2024/11/26 0:28:39

背景

  • 工作中需要在网页上实现甘特图,以展示进度数据。通过网上调研相关项目,找到一款 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>
.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语法绘制,主要用到以下几种常用标签

  • 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;
  })
}
<!-- 时间轴 -->
<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>

进度图

  • 背景绘制
    ① 用 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;
}
<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>
  • 进度图 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>

点击滚动

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

// 点击任务自动滚动
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地址】⭐️

原文地址
【个人博客】⭐️

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/42782.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

PyCharm+PyQT5之一环境搭建

TOCPyCharmPyQT之一环境搭建 今天搭建了PyCharmPyQT之一环境&#xff0c;看到好多论坛和书籍在搭建Python环境时仍然不使用虚拟环境管理&#xff0c;这对今后环境的移植和保存是非常麻烦的&#xff0c;大家可以按以下思路管理环境。 1.先安装python3.8.2&#xff08;我用的win7…

【简单、高效、性能好】SetFit:无需Prompts的高效小样本学习

重磅推荐专栏&#xff1a; 《Transformers自然语言处理系列教程》 手把手带你深入实践Transformers&#xff0c;轻松构建属于自己的NLP智能应用&#xff01; 1. 概要 使用预训练语言模型的小样本学习&#xff08;处理只有少量标签或没有标签的数据&#xff09;已成为比较普遍的…

(附源码)计算机毕业设计Java大学生学科竞赛报名管理系统

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat8.5 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; Springboot mybatis Maven Vue 等等组成&#xff0c;B/…

【Java语言】Java类与对象的详细教程,一看就会

Java类与对象 文章目录Java类与对象1. 类与对象的初步认知2. 类和类的实例化3. 类的成员3.1 字段/属性/成员变量3.1.1认识 null3.1.2字段就地初始化3.2 方法 (method)3.3 static 关键字3.4方法调用易错区分4. 封装4.1 private实现封装4.2 getter和setter方法5.构造方法5.1 基本…

【密码学基础】Oblivious Transfer(不经意传输)

头一次开始学密码学相关的东西&#xff0c;未来的主要研究方向包括了隐私计算&#xff0c;即隐私保护下的机器学习算法。 0 举个实际的例子 引用博客OT&#xff08;Oblivious Transfer&#xff0c;不经意传输&#xff09;协议详解提到的例子&#xff0c;我们这里考虑1-out-of-…

美团应届生面试第一问:Object o = new Object()占用多少字节?

文章目录工具查看内存分配Java内存模型访问对象方式GC为什么Survivor要分为两个区域&#xff08;S0和S1&#xff09;&#xff1f;Survivor 为什么不分更多块呢&#xff1f;对象的生命周期小知识工具查看内存分配 Object o new Object();占用多少字节&#xff0c;我们借助open…

重要公告 | 论坛域名更换,请务必及时收藏

论坛的小伙伴们&#xff1a; 为进一步规范网站域名&#xff0c;自2022年11月16日起&#xff0c;“西门子低代码开发者论坛”的域名由&#xff1a;https://forum.mendix.tencent-cloud.com/&#xff0c;正式变更为&#xff1a;https://marketplace.siemens.com.cn/low-code-com…

Kamiya丨Kamiya艾美捷人和动物LBP ELISA说明书

Kamiya艾美捷人和动物LBP ELISA预期用途&#xff1a; 人和动物LBP ELISA已被开发用于定量测定天然和血清&#xff0c;血浆和培养基中的重组人LBP。也适用于牛&#xff0c;猪&#xff0c;兔和狗LBP。仅供研究使用。不用于诊断程序。 Kamiya艾美捷人和动物LBP ELISA原理&#xf…

地理计算 | 计算两个坐标点射线的交点(前方交会)

1 前言 前方交会--- 又称为测角交会&#xff0c;是指从相邻两个已知点向待定点观测两个水平角&#xff0c;用以计算待定点的坐标。 如图所示&#xff0c;点 A、B 的坐标已知。 通过观测角 A 和角 B 求出点 P 坐标的定位方法被称之为“角度前方交会”&#xff1b; 通过观测方…

汽车租赁系统毕业设计,汽车租赁管理系统设计与实现,毕业设计论文毕设作品参考

功能清单 【后台管理员功能】 广告管理&#xff1a;设置小程序首页轮播图广告和链接 留言列表&#xff1a;所有用户留言信息列表&#xff0c;支持删除 会员列表&#xff1a;查看所有注册会员信息&#xff0c;支持删除 资讯分类&#xff1a;录入、修改、查看、删除资讯分类 录入…

代码随想录训练营day46, 单词拆分和多重背包

今天就这一道题, 但还是有难度的 单词就是物品, 字符串s就是背包, 单词能否组成字符串s, 就是问物品能不能把背包装满 确定dp数组含义: 字符串长度为i的话, dp[i]为true, 表示可以拆分, j是分割指针确定递推公式: 如果确定dp[j]是true, 且[j , i]这个区间的子串出现在字典里,…

案例-Shell定时采集数据到HDFS

1. 准备工作 创建日志文件存放的目录 /export/data/logs/log&#xff0c;执行命令&#xff1a;mkdir -p /export/data/logs/log 创建待上传文件存放的目录/export/data/logs/toupload&#xff0c;执行命令&#xff1a;mkdir -p /export/data/logs/toupload 查看创建的目录树结…

FSC在全球范围内增强品牌相关度,促进公众理解

【FSC在全球范围内增强品牌相关度&#xff0c;促进公众理解】 FSC品牌标识 “森林与共&#xff0c;生生不息”将逐渐精简&#xff0c;同时覆盖更多语种。 加深消费者对FSC的理解 近年来&#xff0c;FSC品牌认知度不断提高&#xff0c;超过半数的全球消费者认可并信任“小树”标…

为什么劝你要学习Golang以及GO语言(Go语言知识普及)

Go语言 一、 Go语言的由来 Go语言亦叫Golang语言&#xff0c;是由谷歌Goggle公司推出。 传统的语言比如c&#xff0c;大家花费太多时间来学习如何使用这门语言&#xff0c;而不是如何更好的表达写作者的思想&#xff0c;同时编译 花费的时间实在太长&#xff0c;对于编写-编译…

C语言只推荐这1本宝藏书,你读过吗?

入门的大家随便搜搜学起来都不会出错&#xff0c;进阶的推荐1本豆瓣评分9.1&#xff0c;这本经典之作真正地让人搞懂了烦人的指针。 指针为什么如此重要&#xff1f;C语言圈内有一句经典的自嘲&#xff1a;C语言就只有指针可以用了。如果你干掉struct、干掉union、干掉数组、甚…

html在线阅读小说网页制作模板 小说书籍网页设计 大学生静态HTML网页源码 dreamweaver网页作业 简单网页课程成品

&#x1f389;精彩专栏推荐 &#x1f4ad;文末获取联系 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 &#x1f482; 作者主页: 【主页——&#x1f680;获取更多优质源码】 &#x1f393; web前端期末大作业&#xff1a; 【&#x1f4da;毕设项目精品实战案例 (10…

管道通信: 有名管道 无名管道,行业大牛通通教会你

管道是一种最古老也是最基本的系统IPC形式&#xff0c;管道就像现实中的水管&#xff0c;水就像数据&#xff0c;它是消息传递的一种特殊方式&#xff0c;管道机制必须提供三方面的协调能力&#xff1a;互斥、同步和确定对方的存在。在Linux中是一种使用非常频繁的通信机制。从…

链表剖析及自己手撸“单链表“实现基本操作(初始化、增、删、改等)

一. 基础 1. 前言 链式存储结构&#xff0c;又叫链表&#xff0c;逻辑上相邻&#xff0c;但是物理位置可以不相邻&#xff0c;因此对链表进行插入和删除时不需要移动数据元素&#xff0c;但是存取数据的效率却很低&#xff0c;分为三类&#xff1a; (1).单(向)链表&#xff1…

【iconfont图标】vue引入并使用阿里巴巴iconfont图标流程

前言 为什么要使用阿里图标库&#xff1a; 图标现在是很多地方都会用到的 一般我使用的时候都是直接在ui库中比如elementul自带的一些 有时候哪怕是感觉图标不是非常适合也是用的elementul图标&#xff0c;主要原因是懒 因为能直接复制的&#xff0c;就懒得再去阿里图标库下载…

如何让Java项目兼容更多的客户端设备(一)

如何让Java项目兼容更多的客户端设备&#xff08;一&#xff09; 引入 HTTP访问是无状态的&#xff0c;&#xff08;服务器不知道是不是你访问的&#xff09;所以我们不知道每次登录的是谁 如果想实现每次登录不用重复登录&#xff0c;最简单的就是让浏览器记住用户名和密码…