如何创建一个普通的JavaScript甘特图——添加任务编辑功能(第2部分)

366 阅读12分钟

如何创建一个普通的JavaScript甘特图。添加任务编辑功能(第2部分)

在本文的第一部分,我们开发了一个交互式甘特图的网络组件。现在,我们将用一些编辑工作的交互可能性来增强甘特图组件:工作条可以通过鼠标拖动来调整大小,而且我们还实现了一个编辑对话,可以用来修改工作的开始和结束日期。在此过程中,我们将继续使用Vanilla JS和Web Components。最后,我们将看看一些JavaScript库,它们可以大大简化开发一个功能齐全的甘特图的工作。

下面的展示了我们在这篇文章中要建立的东西。首先,我们将在每个工作的右侧添加一个拖动手柄,可用于调整工作栏的大小(在图片中,它显示为一个狭窄的灰色竖条)。在下一步,我们将进一步扩展工作的行为,使双击工作栏可以打开一个编辑对话。

带有工作编辑功能的甘特图的新界面

为了遵循本文的指示,你可以使用前一篇文章中的文件作为起点。完成的解决方案的源代码可以在我的GitHub资源库中找到。

由于代码包含JavaScript模块,你只能从HTTP服务器上运行这个例子,而不能从本地文件系统上运行。如果要在你的本地PC上进行测试,我推荐使用模块live-server,你可以通过npm安装。

另外,你也可以直接在浏览器中尝试这个例子,而不需要安装。

工作的网络组件

在我们扩展job的功能之前,我们将与job相关的源代码移到一个单独的组件中,名称为GanttJob

该组件包含工作对象和甘特图时间轴的选定缩放级别(year-monthday )的设置器和获取器。它把一个工作渲染成一个简单的div 元素<div class="job"></div> 。工作条的长度在render 函数中计算,取决于缩放级别和工作的持续时间。

与本文的第一部分相比,我对工作的样式做了一些改变。

注意检查组件VanillaGanttChart 的CSS样式的一些小变化。

const template = document.createElement('template');
  template.innerHTML = 
   `<style>
       @import "./styles/GanttJob.css";
    </style>
    <div class="job"></div>`;
  export default class GanttJob extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
      this.shadowRoot.appendChild(template.content.cloneNode(true));
    }
    connectedCallback() {
        var jobElement = this.shadowRoot.querySelector(".job");
        jobElement.id = this.id;
        jobElement.draggable = true;
        this._render();
    }
    disconnectedCallback() { }
    update(){
      this._render();
    }
    _render(){
      var jobElement = this.shadowRoot.querySelector(".job");
      var d;
      if(this._level == "year-month"){
        d = this._dayDiff(this.job.start, this.job.end);
      }else{//_level = "day"
        d = this._hourDiff(this.job.start, this.job.end);
      }
      jobElement.style.width = `calc(${d*100}% + ${d}px)`;
    }
    _job;
    _level = "year-month";
    set job(newValue){
        this._job = newValue;
        this._render();
    } 
    get job(){
        return this._job;
    }
    set level(newValue)
      this._level = newValue;
    }
    get level(){
        return this._level;
    }
    
    //helper functions _dayDiff, _hourDiff; see example files
}
window.customElements.define('gantt-job', GanttJob);

在类YearMonthRenderer ,你暂时只需要调整函数initJobs 。自定义元素div ,而不是一个简单的jobs 数组中的每个工作的甘特格中插入自定义元素gantt-job 。它被初始化为属性id,job, 和level 。此外,在 Gantt 网格内的工作的拖放实现没有变化。

同样的变化也适用于类DateTimeRenderer

var initJobs = function(){
  this.jobs.forEach(job => {
      var date_string = formatDate(job.start);
      var ganttElement = shadowRoot.querySelector(`div[data-resource="${job.resource}"][data-date="${date_string}"]`);
      if(ganttElement){
        var jobElement = document.createElement("gantt-job");
        jobElement.id = job.id;
        jobElement.job = job;
        jobElement.level = "year-month";
        ganttElement.appendChild(jobElement);         
        jobElement.ondragstart = function(ev){
            ev.dataTransfer.setData("job", ev.target.id);           
        };
      }
  });
}.bind(this);

使工作可调整大小

用户应该能够通过拖动工作栏的右外缘来增加或减少工作的持续时间。此外,改变持续时间应该只能以与当前活动视图中的时间段大小相应的步骤进行。这意味着,如果设置了级别year-month ,持续时间只能以整日为步长进行改变,如果设置了级别day ,持续时间只能以整小时为步长进行改变。

首先,使用CSS在工作栏的右侧添加一个拖动手柄(见文件styles/GanttJob.css ):

.job::after {
       content: '';
       background-color: #646965;
       position: absolute;
       right: 0;
       width: 4px;
       height: 100%;
       cursor: ew-resize;
}

为了使工作可以调整大小,可以使用以下策略:

  1. 当用户点击一个工作的拖动手柄时,就开始调整大小。这意味着我们必须对甘特图中的mousedown 事件做出反应。
  2. 从这一点开始,工作栏会随着用户向左或向右移动鼠标而增加或减少。这种行为可以通过mousemove 事件来控制。
  3. 当用户再次释放鼠标按钮时,大小调整就完成了:一旦mouseup 事件发生。

你的第一个想法可能是在GanttJob 组件内的工作栏上添加一个mousedown 监听器。然而,根据甘特图中工作的数量,这可能导致大量的监听器,从而影响组件的性能。

因此,在这种情况下,我使用的概念是 事件委托.我们没有为每个作业安装一个单独的mousedown 监听器,而是为共同的父元素准确地安装一个监听器。在我们的案例中,这就是ID为gantt-containerdiv 元素,它包含完整的甘特图。

你可以在文件YearMonthRenderer.js 中添加以下代码:

var makeJobsResizable = function(){
    if(checkElements()){
      var container = shadowRoot.querySelector("#gantt-container");        
      container.addEventListener("mousedown", _handleMouseDown, false);
    }
 }.bind(this);


 var _handleMouseDown = function(e){
    if(e.target.tagName == "GANTT-JOB"){
       this.selectedJobElement = e.target;
       if(this.selectedJobElement.isMouseOverDragHandle(e)){            
          this.selectedJob = this.jobs.find(j => j.id == e.target.id);          
          document.addEventListener("mousemove", _handleMouseMove, false);
          document.addEventListener("mouseup", _handleMouseUp, false);
          //use this to disable drag and drop behavior in resize mode
       e.preventDefault();
       }
    }
}.bind(this);

mousedown 处理程序检查用户是否点击了一个GanttJob 元素。如果是这样,还必须检查用户是否已经点击了拖动手柄。只有这样,mousemovemouseup 监听器才会在调整大小的操作过程中安装。

在文件GanttJob.js 中,函数isMouseOverDragHandle 被添加:

isMouseOverDragHandle = function(e){
      var panel = this.shadowRoot.querySelector(".job");
      var current_width = parseInt(getComputedStyle(panel, '').width);
      if (e.offsetX >= (current_width - this._HANDLE_SIZE)) {
        return true;
      }
      return false;
}.bind(this);
//should match the width setting of the drag handle in the file "GanttJob.css" _HANDLE_SIZE = 4;

剩下的处理函数现在被添加到文件YearMonthRenderer.js

在处理程序_handleMouseMove 中,我们首先需要检查鼠标光标目前在甘特图的哪个部分。相应的gantt-item 通过属性data-date 告诉我们工作的新结束日期。通过调用所选GanttJob 元素的update 函数,我们更新工作栏的长度。

正如你在GanttJob 组件的render 函数中所看到的,长度只在与甘特图的时间部分的大小相对应的步骤中被修改。

在处理程序_handleMouseUp ,工作的选择被清除,mousemove 监听器被删除:

var _handleMouseMove = function(ev){            
      var gantt_item = getGanttElementFromPosition(ev.x, ev.y);
      if(this.selectedJob && this.selectedJobElement){
        this.selectedJob.end = new Date(gantt_item.getAttribute("data-date"));
        this.selectedJobElement.update();
      }
 }.bind(this);
 var _handleMouseUp = function(){
      this.selectedJob = null;
      this.selectedJobElement = null;
      document.removeEventListener("mousemove", _handleMouseMove, false);     
 }.bind(this);


 var getGanttElementFromPosition = function(x, y){
    //elementsFromPoint: returns an array of all elements at the coordinates x,y
    var items = shadowRoot.elementsFromPoint(x, y);
    
   //Possibly the mouse pointer is just above the drag handle or above the job bar. Therefore we have to iterate through the array until we arrive at the gantt_item lying behind of it
    var gantt_item = items.find(item => 
                    item.classList.contains("gantt-row-item"));
    return gantt_item;
 }  
 this.selectedJob = null;
 this.selectedJobElement = null;

在最后一步中,我们在函数initJobs 的末尾调用函数makeJobsResizable 。我们还使用文件YearMonthRenderer.js 中的现有函数clear ,以删除mousedownmouseup 监听器(见样本文件)。

//see file “YearMonthRenderer.js” in the example files of this article 
var initJobs = function(){
  …
  makeJobsResizable();

}.bind(this);

以上所示的所有步骤也适用于文件DateTimeRenderer.js

使工作可编辑

一项工作的开始和结束日期也应该可以通过编辑对话框进行修改。为此,我们首先开发了我们自己的网络组件,名称为GanttJobDialog

以下属性从外部分配给对话框:

  • job 为要编辑的工作对象。
  • level 用于时间线的当前缩放级别( 或 )。year-month day
  • xPos 和 ,相对于相关工作栏的位置,对话框左上角的坐标为 和 。yPos x y

render 功能中,根据缩放级别生成用于调整开始和结束日期的适当表单元素。当级别设置为year-month 时,使用类型为date 的输入,当级别为day 时,使用类型为datetime-local 的输入。此外,输入的值是用工作的当前数据初始化的。

对话框中保存按钮的处理程序有以下职责:

  • 从输入字段中读取新的开始和结束日期,并分配给工作对象。
  • CustomEvent事件名称为save ,被触发以通知对话框的调用者(这里是GanttJob 组件),工作数据已经被改变。
  • 对话框是不可见的。

对话框的取消按钮的处理程序发射一个 **CustomEvent**事件名称为cancel ,以通知对话框的调用者没有数据被改变:

const template = document.createElement('template');
  template.innerHTML = 
   `<style>
       @import "./styles/GanttJob.css";
    </style>
    <dialog>
        <h4 id="job_title">Edit Task</h4>
        <form action="#">
        <p>
            <label for="start">Start</label>
            <input id="start_input" name="start">
        </p>
        <p>
            <label for="start">End</label>
            <input id="end_input" name="start">
        </p>
        <p>
            <input type="button" id="cancel_button" value="Cancel">
            <input type="button" id="save_button" value="Save">
        </p>
        </form>  
    </dialog>`;
  export default class GanttJobDialog extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
      this.shadowRoot.appendChild(template.content.cloneNode(true));
    }
    _job;
    _xPos;
    _yPos;
    _level = "year-month";
    connectedCallback() 
        this._render();   
    }
    disconnectedCallback() {
      this.shadowRoot.querySelector("#cancel_button").removeEventListener("click", this._handleCancel);
      this.shadowRoot.querySelector("#save_button").removeEventListener("click", this._handleSave);
      document.removeEventListener("click", this._handleClickOutside);
    }   
    _render(){
        var dialogElement = this.shadowRoot.querySelector("dialog");
        dialogElement.style.left = this._xPos+"px";
        dialogElement.style.top = this._yPos+"px";
        if(this.level == "year-month"){
           this.shadowRoot.querySelector("#start_input").type = "date";
           this.shadowRoot.querySelector("#end_input").type = "date"; 
           this.shadowRoot.querySelector("#start_input").value = `${this.job.start.getFullYear()}-${this.zeroPad(this.job.start.getMonth()+1)}-${this.zeroPad(this.job.start.getDate())}`;
           this.shadowRoot.querySelector("#end_input").value = `${this.job.end.getFullYear()}-${this.zeroPad(this.job.end.getMonth()+1)}-${this.zeroPad(this.job.end.getDate())}`;
        }else{
           this.shadowRoot.querySelector("#start_input").type = "datetime-local";
           this.shadowRoot.querySelector("#end_input").type = "datetime-local";
           this.shadowRoot.querySelector("#start_input").value = `${this.job.start.getFullYear()}-${this.zeroPad(this.job.start.getMonth()+1)}-${this.zeroPad(this.job.start.getDate())}T${this.zeroPad(this.job.start.getHours())}:00`;
           this.shadowRoot.querySelector("#end_input").value = `${this.job.end.getFullYear()}-${this.zeroPad(this.job.end.getMonth()+1)}-${this.zeroPad(this.job.end.getDate())}T${this.zeroPad(this.job.end.getHours())}:00`;
        }
        this.shadowRoot.querySelector("#cancel_button").addEventListener("click", this._handleCancel);
        this.shadowRoot.querySelector("#save_button").addEventListener("click", this._handleSave);
        document.addEventListener("click", this._handleClickOutside);
    }
    _handleCancel = function(e){
        var dialogElement = this.shadowRoot.querySelector("dialog");
        this.dispatchEvent(new CustomEvent("cancel"));
        dialogElement.style.visibility = "hidden";
    }.bind(this);


    _handleSave = function(e){
        var dialogElement = this.shadowRoot.querySelector("dialog");
        this.job.start = new Date(this.shadowRoot.querySelector("#start_input").value);
        this.job.end = new Date(this.shadowRoot.querySelector("#end_input").value);
        this.dispatchEvent(new CustomEvent("save"));
        dialogElement.style.visibility = "hidden";
    }.bind(this);


    //when clicking outside the dialog, the dialog should be canceled as well
    _handleClickOutside = function(e){
        var dialogElement = this.shadowRoot.querySelector("dialog");
        //we need to check whether the click was triggered inside or outside the dialog
        var items = this.shadowRoot.elementsFromPoint(e.offsetX, e.offsetY);
        var close = true;
        items.forEach(item => {
            if(item.tagName == “DIALOG”){
                close = false;
                return;
            }
        });
        if(close){
            this.dispatchEvent(new CustomEvent("cancel"));
            dialogElement.style.visibility = "hidden";
        }
    }.bind(this);


    set job(newValue){
        this._job = newValue;
        this._render();
    }
    get job(){
        return this._job;
    }
    set xPos(newValue){
        this._xPos = newValue;
        this._render();
    }
    set yPos(newValue){
        this._yPos = newValue;
        this._render();
    }
    set level(newValue){
       this._level = newValue;
    }
    get level(){
       return this._level;
    }
    zeroPad(n){return n<10 ? "0"+n : n;}
}
window.customElements.define('gantt-job-dialog', GanttJobDialog);

编辑对话框将通过双击作业栏打开。因此,我们需要一个dblclick 事件的监听器,根据事件委托原则,我们再次将其附加到周围的gantt-container (监听器在函数clear 中被移除;见示例文件)。

为此,我们在文件YearMonthRenderer.js 中添加了函数makeJobsEditable

var makeJobsEditable = function(){
  if(checkElements()){
    var container = shadowRoot.querySelector("#gantt-container");
    container.addEventListener("dblclick", _handleDblClick, false);
  }
}.bind(this);

var _handleDblClick = function(e){
  if(e.target.tagName == "GANTT-JOB"){
    var jobElement = e.target;
    jobElement._handleDblClick();
  }
}

事件的实际处理是在GanttJob 组件中进行的。在这里,GanttJobDialog 组件的一个实例被初始化,并作为工作栏的一个子元素插入DOM中。

在这一点上,我们也实现了由对话框触发的CustomEvents savecancel 的处理程序。这两个处理程序都从DOM中移除对话框。save 处理程序还触发了工作栏长度的更新,并反过来将一个CustomEvent editjob 转发到相关的渲染器(YearMonthRendererDateTimeRenderer ):

_handleDblClick = function(e){
    var panel = this.shadowRoot.querySelector(".job");
    //As long as the dialog is open, the z-index of the job bar is temporarily increased. This ensures that the dialog is not overlapped by other job bars.
    panel.style.zIndex = 101;
    var dialogElement = document.createElement("gantt-job-dialog");
    dialogElement.job = this.job;
    dialogElement.xPos = 0;
    dialogElement.yPos = 40;
    dialogElement.level = this.level;
    panel.appendChild(dialogElement);
    dialogElement.addEventListener("cancel", () => {
      panel.style.zIndex = 100;
      dialogElement.remove();
    });
    dialogElement.addEventListener("save", () => {
      this.update();
      panel.style.zIndex = 100;
      dialogElement.remove();
      this.dispatchEvent(new CustomEvent("editjob"));
    });
}.bind(this);

回到文件YearMonthRenderer.js 中,我们必须再次扩展函数initJobs 。首先,为事件editjob 定义一个处理程序,以确保在改变开始时间的情况下,工作被重新插入甘特图的正确位置。其次,对makeJobsEditable 的调用被放置在函数的最后:

var initJobs = function{

   ... 
   jobElement.ondragstart = function(ev){
        ev.dataTransfer.setData("job", ev.target.id);           
   };
   jobElement.addEventListener("editjob", (ev) =>{
        var date_string = formatDate(job.start);
        var gantt_item = shadowRoot.querySelector(`div[data-resource="${job.resource}"][data-date="${date_string}"]`);
        gantt_item.appendChild(jobElement);          
    });
    ... 
    makeJobsResizable();
    makeJobsEditable();
}

所有显示的步骤也适用于文件DateTimeRenderer.js

用于甘特图和调度的特殊库

通过本文第二部分介绍的代码改进,甘特图已经变得更有互动性和用户友好。尽管如此,仍有一些事情没有解决。例如,当放弃一项工作时,工作栏的左侧(这意味着开始时间)会跳到刚刚在鼠标指针下方的gantt 单元。相反,无论鼠标指针在工作栏上的位置如何,工作都应该准确地定位在工作栏左侧所指向的单元格中

除此之外,我们还需要一些改进,使与甘特图的交互更加顺畅,比如根据屏幕大小灵活调整图表,或根据可用空间灵活定位编辑对话框

目前的图表组件可以被配置为甘特图和资源调度器。如果作为甘特图使用,一些规则仍然需要被实现。例如,在这种情况下,一项工作可能不会从一行移到下一行。此外,还需要更多的甘特图特定的配置选项,如工作之间的顺序依赖性将任务分层组织成子任务

用户可能会期望在生产用的专业甘特图中有更多的功能:

  • 在任务和子任务的WBS树中重新安排行的顺序
  • 筛选、排序、在线单元格编辑
  • 跟踪数据变化
  • 以不同的时间粒度(天与月与年)放大到不同的视图
  • 任务之间的依赖关系
  • 非工作时间
  • 工具提示
  • ...以及更多

所以,如果你想进一步开发你自己的甘特图或日程安排组件,这些是一些需要考虑的功能想法。

然而,如果你正在寻找一个可以轻松集成到你的网络应用中的甘特图解决方案,可能值得看看现有的第三方JavaScript甘特图库。这类库包含预建的、复杂的组件,可以节省你开发和维护自己的甘特图部件的大量精力。

让我们来看看三个专业甘特图库的例子。

Syncfusion的JavaScript甘特图

对甘特图库的处理通常是非常舒适的。通常一个JavaScript文件就足以根据你的喜好来配置图表,并将数据填入其中。

例如,让我们看一下 index.js文件,在那里我们用工作和资源的必要数据初始化我们的甘特图。我们的组件VanillaGanttChart ,只是在文件的开头被导入。这就是现成库的工作方式:在导入所需的脚本和模块后,你可以立即开始将数据输入甘特图,或根据你的愿望配置甘特图。

让我们来看看Syncfusion的JavaScript甘特图。这是一个商业工具,"用于显示和管理具有时间线细节的分层任务"。

在该工具文档的 "入门 "部分,你将学习如何用一些基本选项(例如,任务编辑、过滤、排序和定义任务关系的选项)初始化一个简单的甘特图。使用Syncfusion,第一个简单的甘特图可以是这样的。

Simple Gantt Chart built with the JavaScript Gantt control by Syncfusion

在此基础上,图表的功能还可以进一步扩展。

Bryntum的JavaScript甘特图

另一个值得考虑的例子是Bryntum Gantt库,"一个超快的、完全可定制的甘特图套件"。

下载该库的免费试用版后,你会得到一个包含CSS和JavaScript文件的构建文件夹,用于创建交互式甘特图。你可以将这些文件整合到你的网络应用中,然后立即配置你的个人图表。一个简单的入门指南提供了对该组件的快速介绍。例如,使用Bryntum Gantt库,一个基本图表可以是这样的。

Simple Gantt Chart built with Bryntum Gantt

完整的Bryntum Gantt文档中,你会学到很多关于众多定制选项的知识。你还将探索如何将该工具与Angular、React、Vue等流行框架集成,或如何组织数据的加载和保存(CRUD数据管理)。

示例部分对Bryntum Gantt的各种功能进行了直观的概述。

An extract from the showcase of interactive examples of Bryntum Gantt Charts

他们还提供了Bryntum Scheduler- 一个用于资源规划的库。

Webix的JavaScript甘特图

有了Webix Gantt,就有了另一个具有丰富功能的商业甘特图库。安装、创建和配置甘特图的简单步骤都有详细的记录

你可以在一个全屏互动演示中试用该工具:

Simple Gantt Chart built with Webix Gantt

结论

甘特图是项目管理、计划和任务组织的一个有价值的可视化工具。有很多方法可以将甘特图集成到网络应用中。在上两篇文章中,我们从头开始构建了一个交互式甘特图,在此过程中,我们学到了很多关于CSS网格、Web组件和JavaScript事件的知识。如果你有更复杂的要求,值得看看商业JS库,它们通常都很强大。