如何结合antd开发一个svg伸缩甘特图Gantt(二)超详细!!

4,481 阅读5分钟

total.gif

前言

上一篇介绍了如何实现布局,本篇就具体来聊聊如何使用svg实现甘特图,由于篇幅过长,我将把如何改造antd tree为antd table放在另一篇文章

what is svg

正式开始前,先放个svg介绍: svg 文档

可缩放矢量图形Scalable Vector Graphics,SVG),是一种用于描述二维的矢量图形,基于 XML 的标记语言。作为一个基于文本的开放网络标准,SVG能够优雅而简洁地渲染不同大小的图形,并和CSSDOMJavaScriptSMIL等其他网络标准无缝衔接。本质上,SVG 相对于图像,就好比 HTML 相对于文本。

现实前端开发中,svg常常是作为页面中的小icon存在,在移动开发中,还会作为一张背景图,主要是为了性能优化,减少网络请求。

而这次的gantt开发,不仅用到了svg,还用到了其他一些svg的常用标签

rect 钜形标签

专有属性

  • x 定义矩形左侧位置
  • y 定义矩形顶端位置
  • width 定义矩形宽度
  • height 定义矩形高度
  • rx 定义矩形x轴半径
  • ry (en-US) 定义矩形y轴半径

rect

<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
  <rect x="20"  y="120" width="60" height="60" ry="0"   rx="15"/>
  <rect x="120" y="120" width="60" height="60" ry="15"  rx="15"/>
  <rect x="220" y="120" width="60" height="60" ry="150" rx="15"/>
</svg>

rect 标签在gantt图中主要用于绘制 任务小块bar,还有背景条

path 路径标签

path元素是用来定义形状的通用元素。所有的基本形状都可以用path元素来创建。

用path来绘制图形相对比较复杂,这里建议直接使用svg编辑器绘制,目前许多绘图软件都支持直接导出svg格式的图片,而在gantt图中,主要用于绘制竖线,用来分割时间,所以这里简单介绍一下如何利用path绘制直线。

下面的命令可用于路径数据(可以使用大小写,大写为绝对位置,小写为相对位置):

  • M = move to 传入目标点的x轴位置、y轴位置 => M2 0
  • H = horizontal lineto 绘制平行线
  • V = vertical lineto 绘制垂直直线
// 距离左侧100 绘制了一个长度为100的直线,直线宽度为3
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
    <path d="M100 0 V 100" stroke="orange" stoke-width="3" />
</svg>

line 标签

line元素是一个SVG基本形状,用来创建一条连接两个点的线。

  • x1 = 第一个点的x 轴位置
  • y1 = 第一个点的y 轴位置
  • x2 = 第二个点的x 轴位置
  • y2 = 第二个点的y 轴位置
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
    <line x1="0" y1="20" x1="0" x1="20" y1="20" stroke="orange" stoke-width="3" />
</svg>

text 文本标签

text元素定义了一个由文字组成的图形。可以将渐变、图案、剪切路径、遮罩或者滤镜应用到text上。在gantt中用来展示时间。

// 在 x 为10 y 为10的位置绘制文字 october
<svg viewBox="0 0 300 200" xmlns="http://www.w3.org/2000/svg">
    <text x="10" y="10">October</text>
</svg>

g 组合标签

g是用来组合对象的容器。添加到g元素上的变换会应用到其所有的子元素上。添加到g元素的属性会被其所有的子元素继承。在gantt图中,用于组合对象。

<svg width="100%" height="100%" viewBox="0 0 95 50" xmlns="http://www.w3.org/2000/svg">
  <g fill="orange" stroke-width="5">
     <rect x="20"  y="120" width="60" height="60" ry="0"   rx="15"/>
     <rect x="120" y="120" width="60" height="60" ry="15"  rx="15"/>
     <rect x="220" y="120" width="60" height="60" ry="150" rx="15"/>
  </g>
</svg>

Gantt 组件分析

介绍完需要用到的标签,就可以正式开始绘制gantt啦,先看看效果:

截屏2021-11-06 下午5.31.11.png

由图像可将Gantt分为以下几个大块:

  1. 时间组件
  2. 背景组件
  3. Bar组件

gantt 还支持缩放(目前仅支持视觉上的缩放,不涉及季度或星期,年份的切换),通过系数confficient 来维护gantt的缩放比例,1为最大,0.6为最小~

const coefficientList = [1, 0.8, 0.6]

Gantt 绘制

数据处理(时间组件)

数据是gantt最重要的部分,读取数据,得到甘特图的绘制时间范围以及每个任务bar的位置。而这一个任务则交给gantt的头部时间展示处理。 截屏2021-11-27 下午2.57.36.png

需求分析:

  1. 需要知道时间范围
  2. 需要知道当前columnwidth 时间宽度 = basicColumnwidth * coefficient

截屏2021-12-04 上午10.53.40.png

第一次遍历

获取列表中的最早时间和最晚时间

  const setupGanttDates = () => {
    // 初始化为当前时间
    const iniDate = {
      ganttStart: moment().startOf('d').toDate(),
      ganttEnd: moment().endOf('d').toDate(),
    };
    
    tasks.forEach(task => {
      const {startDate, endDate} = task;
      // 判断改startDate是否小于上一个最小startDate,是的话时间交换
      if (startDate && startDate < tempDate.ganttStart) {
        tempDate.ganttStart = task.startDate;
      }
       // 判断改endDate时间是否小于上一个最大endDate时间,是的话时间交换
      if (endDate && endDate > tempDate.ganttEnd) {
        tempDate.ganttEnd = task.endDate;
      }
    });
    // 如果开始时间和结束时间相差小于20天,开始和结束时间分别要减少和增加一个月的时间
    // 保证页面样式可以展示时间刻度
    if (moment(tempDate.ganttEnd).diff(moment(tempDate.ganttStart), 'd') < 20) {
      dateRange.current = {
        ganttStart: moment(tempDate.ganttStart).subtract(1, 'month').toDate(),
        ganttEnd: moment(tempDate.ganttEnd).add(1, 'month').toDate(),
      };
    } else {
      // 为便于查看,开始和结束时间也要分别减少增加7天,给前后留白
      dateRange.current = {
        ganttStart: moment(tempDate.ganttStart).subtract(1, 'week').toDate(),
        ganttEnd: moment(tempDate.ganttEnd).add(1, 'week').toDate(),
      };
    }
  };
绘制时间

当前gantt的总天数为ganttEnd - ganttStart

知道了总共需要的绘制天数,也知道时间的宽度,便可以绘制时间组件了

// svgWidth(时间组件宽度) = 总天数 * columWidth
// svgHeight(时间组件高度) = 默认高度
// x(文字x轴位置) = 天数 * columWith
// y默认高度) = 默认时间高度
<svg width={svgWidth} height={svgHeight}>
    <g className="date">
      <text x={x} y={y} >{日期}</text>
      ...
    </g>
</svg>

背景组件

背景的绘制就比较简单啦,结合前面说到的,用rect标签和path绘制, 分别遍历ganttData和date

背景

// svgWidth (背景宽度) = 总天数 * columWidth
// svgHeight(背景高度) = data.length(当前任务数) * rowHeight(默认高度)
// x = 天数 * columWith
// rowY(Y轴) = dataIndex * barHeight
// rowWidth = svgWidth
// 此处模拟过程,不符合jsx语法

<svg width={svgWidth} height={svgHeight}>
    // 绘制横向元素
    data.forEach(() => {
        <line x1={0} y1={rowY+ rowHeight}  x2={rowWidth} y2={rowY+ rowHeight} />
        <rect x={0} y={rowY} width={svgWidth} height={rowHeight} />
        // bar的高度 + bar上下padding
        rowY += barHeight + barPadding;
    });
    
    // 绘制纵向元素
    dates.forEach(item => {
      let tickClass = 'tick';
      // new Date().getDate() => 判断是不是本月的1号
      if (item.getDate() === 1) {
      // 假如是1号需要加粗
        tickClass += ' thick';
      }
      <path d: `M ${x} ${0} v ${svgHeight}` classname={tickClass} />
      x += columWith;
    });
</svg>

绘制Bar

bar

除了绘制bar的位置及大小,还需要实现点击展示详情,这里用了antd的 Popover组件

// width = task天数 * columnWidth
// height = barHeight
// x = 天数 * columWith
// y = dataIndex * rowHeight
// color = 默认颜色
// cornerRadius = 默认角度
<g>
    <Popover content={content} title={title} placement='bottomRight'>
      <rect
        x={x}
        y={y}
        width={width}
        height={height}
        rx={cornerRadius}
        ry={cornerRadius}
        fill={color}
      />
    </Popover>
    ...
</g>

Gantt交互优化

自动定位

12.gif

需求分析:

用户左侧点击task, 右侧bar滑到最左侧

情况一(橙色为目标小块)

已滚动距离为0

1.png

  • x1 = tableDom.getBoundingClientRect().left
  • x2 = selectBar.getBoundingClientRect().right

实际需要滚动的距离为: diff = X2 - X1

rightRef.current.scrollTo({
    left: diff,
    behavior: 'smooth',
});

情况二(橙色为目标小块)

已经滚动了x1 2.png

  • x1 = rightRef.current.scrollLeft
  • x2 = tableDom.getBoundingClientRect().left
  • x3 = selectBar.getBoundingClientRect().right 实际需要滚动的距离为: diff = X3 - X2 + X1
const handleSekect = (selectedKeys: Key[]) => {
      const id = selectedKeys && (selectedKeys[0] as string)?.split('_')[0];
      // 获取目标小块
      const targetBar = document.querySelector(`g[data-id="${id}"]`);
      // 左侧table
      const tableRef = (ref as React.RefObject<HTMLElement>).current;
      if (targetBar && tableRef) {
        // 目标小块左侧距离client距离
        const x = targetBar.getBoundingClientRect().left;
        // table右侧距离client距离
        const parentX = tableRef.getBoundingClientRect().right;
        // 上次gantt滚动的距离
        const preScroll = props.rightRef.current?.scrollLeft || 0;
        const diff = x - parentX;
        props.rightRef.current?.scrollTo({
          left: preScroll + diff,
          behavior: 'smooth',
        });
      }
    };

Gantt 缩放

12.gif

需求分析:

gantt随着用户点击缩放进行缩放,且视图与缩放前一致

注意: 缩放前后的滚动距离比例实际与gantt伸缩比例一致

const handleClick = (type: ClickType) => {
    // 防止用户多次点击
    if (!delay) {
        setDelay(true);
        const ganttRef = (ref as React.RefObject<HTMLDivElement>).current;
        const preScroll = ganttRef?.scrollLeft || 0;
        const oldIndex = coefficient;
        const newIndex = type === ClickType.Add ? oldIndex - 1 : oldIndex + 1;
        const newScroll =
          (preScroll * coefficientList[newIndex]) /
          coefficientList[coefficient];
        setCoefficient(newIndex);
        // 等待svg绘制结束
        setTimeout(() => {
          if (newScroll) {
            ganttRef?.scrollTo({
              left: newScroll,
            });
          }
          setDelay(false);
        }, 800);
    }
};

总结

以上就是整个gantt绘制的逻辑。文章主要侧重描述逻辑,没有放太多代码,源码在GitHub

由于团队的需求仅仅是展示数据,而且数据量非常大,为了不那么笨重,使用了svg,没有使用div。假如希望实现gantt拖拽的效果,大家可以也可以使用div的~

链接

如何结合antd开发一个svg伸缩甘特图Gantt(一)超详细!!

改造antdTree!它成了你想象中的样子🌝