Tạo thành phần biểu đồ Gantt tương tác bằng Vanilla JavaScript (Phần 1)

theanh

Administrator
Nhân viên
Nếu bạn làm việc với dữ liệu thời gian trong ứng dụng của mình, hình ảnh trực quan dưới dạng lịch hoặc biểu đồ Gantt thường rất hữu ích. Thoạt nhìn, việc phát triển thành phần biểu đồ của riêng bạn có vẻ khá phức tạp. Do đó, trong bài viết này, tôi sẽ phát triển nền tảng cho thành phần biểu đồ Gantt mà bạn có thể tùy chỉnh giao diện và chức năng cho bất kỳ trường hợp sử dụng nào.

Sau đây là các tính năng cơ bản của biểu đồ Gantt mà tôi muốn triển khai:
  • Người dùng có thể chọn giữa hai chế độ xem: năm/tháng hoặc tháng/ngày.
  • Người dùng có thể xác định đường chân trời lập kế hoạch bằng cách chọn ngày bắt đầu và ngày kết thúc.
  • Biểu đồ hiển thị danh sách các công việc nhất định có thể di chuyển bằng cách kéo và thả. Những thay đổi được phản ánh trong trạng thái của các đối tượng.*
Bên dưới, bạn có thể thấy biểu đồ Gantt kết quả ở cả hai chế độ xem. Trong phiên bản hàng tháng, tôi đã đưa vào ba công việc làm ví dụ.





Bên dưới, bạn có thể thấy biểu đồ Gantt kết quả ở cả hai chế độ xem. Trong phiên bản hàng tháng, tôi đã đưa vào ba công việc làm ví dụ.

Các tệp mẫu và hướng dẫn để chạy mã​

Bạn có thể tìm thấy đoạn mã đầy đủ của bài viết này trong các tệp sau:
Vì mã chứa các mô-đun JavaScript nên bạn chỉ có thể chạy ví dụ từ máy chủ HTTP chứ không phải từ hệ thống tệp cục bộ. Để thử nghiệm trên PC cục bộ của bạn, tôi khuyên bạn nên sử dụng mô-đun live-server, bạn có thể cài đặt qua npm.

Cấu trúc cơ bản của thành phần web​

Tôi quyết định triển khai biểu đồ Gantt dưới dạng thành phần web. Điều này cho phép chúng ta tạo một phần tử HTML tùy chỉnh, trong trường hợp của tôi là , mà chúng ta có thể dễ dàng sử dụng lại ở bất kỳ đâu trên bất kỳ trang HTML nào.

Bạn có thể tìm thấy một số thông tin cơ bản về việc phát triển các thành phần web trong MDN Web Docs. Danh sách sau đây hiển thị cấu trúc của thành phần. Nó được lấy cảm hứng từ ví dụ "counter" từ Alligator.io.

Thành phần này định nghĩa một mẫu chứa mã HTML cần thiết để hiển thị biểu đồ Gantt. Để biết thông số kỹ thuật CSS đầy đủ, vui lòng tham khảo các tệp mẫu. Các trường lựa chọn cụ thể cho năm, tháng hoặc ngày chưa thể được định nghĩa ở đây vì chúng phụ thuộc vào cấp độ đã chọn của chế độ xem.

Các phần tử lựa chọn được chiếu vào bởi một trong hai lớp kết xuất thay vào đó. Điều tương tự cũng áp dụng cho việc kết xuất biểu đồ Gantt thực tế vào phần tử có ID gantt-container, cũng được xử lý bởi lớp kết xuất chịu trách nhiệm.

Lớp VanillaGanttChart hiện mô tả hành vi của phần tử HTML mới của chúng ta. Trong trình xây dựng, trước tiên chúng ta định nghĩa mẫu thô của mình là shadow DOM của phần tử.

Thành phần phải được khởi tạo bằng hai mảng, jobsresources. Mảng jobs chứa các tác vụ được hiển thị trong biểu đồ dưới dạng các thanh màu xanh lá cây có thể di chuyển. Mảng resources định nghĩa các hàng riêng lẻ trong biểu đồ nơi các tác vụ có thể được chỉ định. Ví dụ, trong các ảnh chụp màn hình ở trên, chúng ta có 4 tài nguyên được gắn nhãn từ Nhiệm vụ 1 đến Nhiệm vụ 4. Do đó, các tài nguyên có thể biểu diễn các tác vụ riêng lẻ, nhưng cũng có thể biểu diễn con người, phương tiện và các tài nguyên vật lý khác, cho phép sử dụng trong nhiều trường hợp khác nhau.

Hiện tại, YearMonthRenderer được sử dụng làm trình kết xuất mặc định. Ngay khi người dùng chọn một cấp độ khác, trình kết xuất được thay đổi trong phương thức changeLevel: Đầu tiên, các phần tử DOM và trình lắng nghe dành riêng cho trình kết xuất bị xóa khỏi Shadow DOM bằng phương thức clear của trình kết xuất cũ. Sau đó, trình kết xuất mới được khởi tạo với các công việc và tài nguyên hiện có và quá trình kết xuất được bắt đầu.
Mã:
import {YearMonthRenderer} from './YearMonthRenderer.js';import {DateTimeRenderer} from './DateTimeRenderer.js';const template = document.createElement('template');template.innerHTML = ` …    Tháng/Ngày Ngày/Giờ   From   To    `;export default class VanillaGanttChart extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.appendChild(template.content.cloneNode(true)); this.levelSelect = this.shadowRoot.querySelector('#select-level'); } _resources = []; _jobs = []; _renderer; đặt resources(list){…} lấy resources(){…} đặt jobs(list){…} lấy jobs(){…} lấy level() {…} đặt level(newValue) {…} lấy renderer(){…} đặt renderer(r){…} connectedCallback() { this.changeLevel = this.changeLevel.bind(this); this.levelSelect.addEventListener('change', this.changeLevel); this.level = "year-month"; this.renderer = new YearMonthRenderer(this.shadowRoot); this.renderer.dateFrom = new Date(2021,5,1); this.renderer.dateTo = new Date(2021,5,24); this.renderer.render(); } disconnectedCallback() { if(this.levelSelect) this.levelSelect.removeEventListener('thay đổi', this.changeLevel); if(this.renderer) this.renderer.clear(); } changeLevel(){ if(this.renderer) this.renderer.clear(); var r; if(this.level == "năm-tháng"){ r = new YearMonthRenderer(this.shadowRoot); }else{ r = new DateTimeRenderer(this.shadowRoot); } r.dateFrom = new Date(2021,5,1); r.dateTo = new Date(2021,5,24); r.resources = this.resources; r.jobs = this.jobs; r.render(); this.renderer = r; } } window.customElements.define('gantt-chart', VanillaGanttChart);
Trước khi đi sâu hơn vào quá trình kết xuất, tôi muốn cung cấp cho bạn tổng quan về các kết nối giữa các tập lệnh khác nhau:
  • index.html là trang web của bạn, nơi bạn có thể sử dụng thẻ
  • index.js là một tập lệnh trong đó bạn khởi tạo phiên bản của thành phần web được liên kết với biểu đồ Gantt được sử dụng trong index.html với các công việc và tài nguyên phù hợp (tất nhiên bạn cũng có thể sử dụng nhiều biểu đồ Gantt và do đó nhiều phiên bản của thành phần web)
  • Thành phần VanillaGanttChart ủy quyền kết xuất cho hai lớp kết xuất YearMonthRendererDateTimeRenderer.


Kết xuất biểu đồ Gantt bằng JavaScript và lưới CSS​

Sau đây, chúng tôi thảo luận về quy trình kết xuất bằng cách sử dụng YearMonthRenderer làm ví dụ. Xin lưu ý rằng tôi đã sử dụng cái gọi là hàm dựng thay vì từ khóa class để định nghĩa lớp. Điều này cho phép tôi phân biệt giữa các thuộc tính công khai (this.renderthis.clear) và các biến riêng tư (được định nghĩa bằng var).

Quá trình kết xuất biểu đồ được chia thành một số bước phụ:
  1. initSettings
    Kết xuất các điều khiển được sử dụng để xác định đường chân trời lập kế hoạch.
  2. initGantt
    Kết xuất biểu đồ Gantt, về cơ bản gồm bốn bước:
  • initFirstRow (vẽ 1 hàng với tên tháng)
  • initSecondRow (vẽ 1 hàng với các ngày trong tháng)
  • initGanttRows (vẽ 1 hàng cho mỗi tài nguyên với các ô lưới cho mỗi ngày trong tháng)
  • initJobs (vị trí các công việc có thể kéo trong biểu đồ)
Mã:
export function YearMonthRenderer(root){ var shadowRoot = root; var names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; this.resources=[]; this.jobs = []; this.dateFrom = new Date(); this.dateTo = new Date(); //chọn các phần tử var monthSelectFrom; var yearSelectFrom; var monthSelectTo; var yearSelectTo; var getYearFrom = function() {…} var setYearFrom = function(newValue) {…} var getYearTo = function() {…} var setYearTo = function(newValue) {…} var getMonthFrom = function() {…} var setMonthFrom = function(newValue) {…} var getMonthTo = function() {…} var setMonthFrom = function(newValue) {…} var getMonthTo = function() {…} var setMonthTo = function(newValue) {…} this.render = function(){ this.clear(); initSettings(); initGantt(); } //xóa các phần tử và trình lắng nghe được chọn, xóa gantt-container this.clear = function(){…} //thêm mã HTML cho vùng cài đặt (các phần tử được chọn) vào gốc shadow, khởi tạo các phần tử DOM liên quan và gán chúng cho các thuộc tính monthSelectFrom, monthSelectTo, v.v., khởi tạo trình lắng nghe cho các phần tử được chọn var initSettings = function(){…} //thêm mã HTML cho vùng biểu đồ gantt vào gốc shadow, định vị các công việc có thể kéo trong biểu đồ var initGantt = function(){…} //được initGantt sử dụng: vẽ trục thời gian của biểu đồ, tên tháng var initFirstRow = function(){…} //được initGantt sử dụng: vẽ trục thời gian của biểu đồ, các ngày trong tháng var initSecondRow = function(){…} //được initGantt sử dụng: vẽ lưới còn lại của biểu đồ var initGanttRows = function(){…}.bind(this); //được initGantt sử dụng: vị trí các công việc có thể kéo trong các ô biểu đồ var initJobs = function(){…}.bind(this); //thả trình lắng nghe sự kiện cho các công việc var onJobDrop = function(ev){…}.bind(this); //các hàm trợ giúp, hãy xem các tệp ví dụ ...}

Kết xuất lưới​

Tôi khuyên dùng CSS Grid để vẽ vùng sơ đồ vì nó giúp tạo bố cục nhiều cột có thể điều chỉnh động theo kích thước màn hình rất dễ dàng.

Trong bước đầu tiên, chúng ta phải xác định số cột của lưới. Khi thực hiện như vậy, chúng ta tham chiếu đến hàng đầu tiên của biểu đồ (trong trường hợp của YearMonthRenderer) biểu diễn các tháng riêng lẻ.

Do đó, chúng ta cần:
  • một cột cho tên của các tài nguyên, ví dụ: có chiều rộng cố định là 100px.
  • một cột cho mỗi tháng, có cùng kích thước và sử dụng toàn bộ không gian có sẵn.
Điều này có thể đạt được bằng cách thiết lập 100px repeat(${n_months}, 1fr) cho thuộc tính gridTemplateColumns của vùng chứa biểu đồ.

Đây là phần đầu tiên của phương thức initGantt:
Mã:
var container = shadowRoot.querySelector("#gantt-container");container.innerHTML = "";var first_month = new Date(getYearFrom(), getMonthFrom(), 1);var last_month = new Date(getYearTo(), getMonthTo(), 1);//monthDiff được định nghĩa là một hàm trợ giúp ở cuối tệpvar n_months = monthDiff(first_month, last_month)+1;container.style.gridTemplateColumns = `100px repeat(${n_months},1fr)`;
Trong hình ảnh sau, bạn có thể thấy biểu đồ trong hai tháng với n_months=2:



Sau khi đã xác định các cột bên ngoài, chúng ta có thể bắt đầu lấp đầy lưới. Chúng ta hãy tiếp tục với ví dụ từ hình ảnh trên. Ở hàng đầu tiên, tôi chèn 3 div với các lớp gantt-row-resourcegantt-row-period. Bạn có thể tìm thấy chúng trong đoạn trích sau từ trình kiểm tra DOM.

Ở hàng thứ hai, tôi sử dụng cùng ba div để giữ nguyên căn chỉnh theo chiều dọc. Tuy nhiên, div tháng sẽ lấy các phần tử con cho từng ngày trong tháng.
Mã:
  Tháng 6 năm 2021 Tháng 7 năm 2021   1 2 3 4 5 6 7 8 9 10 ...  ...
Để các phần tử con cũng được sắp xếp theo chiều ngang, chúng ta cần thiết lập display: grid cho lớp gantt-row-period. Ngoài ra, chúng ta không biết chính xác cần bao nhiêu cột cho từng tháng (28, 30 hoặc 31). Do đó, tôi sử dụng thiết lập grid-auto-columns. Với giá trị minmax(20px, 1fr);, tôi có thể đảm bảo duy trì chiều rộng tối thiểu là 20px và nếu không thì không gian khả dụng sẽ được sử dụng đầy đủ:
Mã:
#gantt-container { display: grid;}.gantt-row-resource { background-color: whitesmoke; color: rgba(0, 0, 0, 0.726); border: 1px solid rgb(133, 129, 129); text-align: center;}.gantt-row-period { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(20px, 1fr); background-color: whitesmoke; color: rgba(0, 0, 0, 0.726); border: 1px solid rgb(133, 129, 129); text-align: center;}
Các hàng còn lại được tạo theo hàng thứ hai, tuy nhiên là các ô trống.

Sau đây là mã JavaScript để tạo các ô lưới riêng lẻ của hàng đầu tiên. Các phương thức initSecondRowinitGanttRows có cấu trúc tương tự nhau.
Mã:
var initFirstRow = function(){ if(checkElements()){ var container = shadowRoot.querySelector("#gantt-container"); var first_month = new Date(getYearFrom(), getMonthFrom(), 1); var last_month = new Date(getYearTo(), getMonthTo(), 1); var resource = document.createElement("div"); resource.className = "gantt-row-resource"; container.appendChild(resource); var month = new Date(first_month); for(month; month  { 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("div"); jobElement.className="job"; jobElement.id = job.id; //hàm trợ giúp dayDiff - lấy sự khác biệt giữa ngày bắt đầu và ngày kết thúc var d = dayDiff(job.start, job.end); //d --> số ô lưới được job bao phủ + tổng borderWidths jobElement.style.width = "calc("+(d*100)+"% + "+ d+"px)"; jobElement.draggable = "true"; jobElement.ondragstart = function(ev){ //id được sử dụng để xác định công việc khi nó bị hủy ev.dataTransfer.setData("job", ev.target.id); }; ganttElement.appendChild(jobElement); } }); }.bind(this);
Để làm cho job có thể kéo, cần thực hiện ba bước sau:
  • Đặt thuộc tính draggable của phần tử job thành true (xem danh sách ở trên).
  • Xác định trình xử lý sự kiện cho sự kiện ondragstart của phần tử job (xem danh sách ở trên).
  • Xác định trình xử lý sự kiện cho sự kiện ondrop đối với các ô lưới của biểu đồ Gantt, là các mục tiêu thả có thể có của phần tử job (xem hàm initGanttRows trong tệp YearMonthRenderer.js).
Trình xử lý sự kiện cho sự kiện ondrop được xác định như sau:
Mã:
var onJobDrop = function(ev){ // basic null kiểm tra if (checkElements()) { ev.preventDefault(); // drop target = ô lưới, nơi công việc sắp bị drop var gantt_item = ev.target; // ngăn không cho một công việc được thêm vào một công việc khác chứ không phải vào một ô lưới if (ev.target.classList.contains("job")) { gantt_item = ev.target.parentNode; } // xác định công việc đã kéo var data = ev.dataTransfer.getData("job"); var jobElement = shadowRoot.getElementById(data); // drop công việc gantt_item.appendChild(jobElement); // cập nhật các thuộc tính của đối tượng công việc var job = this.jobs.find(j => j.id == data ); var start = new Date(gantt_item.getAttribute("data-date")); var end = new Date(start); kết thúc. setDate(start.getDate()+dayDiff(job.start, job.end)); job.start = bắt đầu; job.end = kết thúc; job.resource = gantt_item.getAttribute("data-resource"); } }.bind(this);
Tất cả các thay đổi đối với dữ liệu công việc được thực hiện bằng cách kéo và thả do đó được phản ánh trong danh sách jobs của thành phần biểu đồ Gantt.

Tích hợp thành phần biểu đồ Gantt vào ứng dụng của bạn​

Bạn có thể sử dụng thẻ ở bất kỳ đâu trong các tệp HTML của ứng dụng (trong trường hợp của tôi là trong tệp index.html) theo các điều kiện sau:
  • Tập lệnh VanillaGanttChart.js phải được tích hợp dưới dạng một mô-đun để thẻ được diễn giải chính xác.
  • Bạn cần một tập lệnh riêng trong đó biểu đồ Gantt được khởi tạo bằng jobsresources (trong trường hợp của tôi là tệp index.js).
Mã:
   Biểu đồ Gantt - Vanilla JS
Ví dụ, trong trường hợp của tôi, tệp index.js trông như sau:
Mã:
import VanillaGanttChart from "./VanillaGanttChart.js";var chart = document.querySelector("#g1");chart.jobs = [ {id: "j1", start: new Date("2021/6/1"), end: new Date("2021/6/4"), resource: 1}, {id: "j2", start: new Date("2021/6/4"), end: new Date("2021/6/13"), resource: 2}, {id: "j3", start: new Date("2021/6/13"), end: new Date("2021/6/21"), resource: 3},];chart.resources = [{id:1, name: "Nhiệm vụ 1"}, {id:2, name: "Nhiệm vụ 2"}, {id:3, name: "Nhiệm vụ 3"}, {id:4, name: "Nhiệm vụ 4"}];
Tuy nhiên, vẫn còn một yêu cầu mở: khi người dùng thực hiện thay đổi bằng cách kéo các công việc trong biểu đồ Gantt, các thay đổi tương ứng trong các giá trị thuộc tính của các công việc phải được phản ánh trong danh sách bên ngoài thành phần.

Chúng ta có thể đạt được điều này bằng cách sử dụng Đối tượng Proxy JavaScript: Mỗi công việc được lồng trong một đối tượng proxy, mà chúng tôi cung cấp với cái gọi là trình xác thực. Nó trở nên hoạt động ngay khi một thuộc tính của đối tượng được thay đổi (hàm set của trình xác thực) hoặc được truy xuất (hàm get của trình xác thực). Trong hàm set của trình xác thực, chúng ta có thể lưu trữ mã được thực thi bất cứ khi nào thời gian bắt đầu hoặc tài nguyên của tác vụ thay đổi.

Danh sách sau đây hiển thị phiên bản khác của tệp index.js. Bây giờ, danh sách các đối tượng proxy được gán cho thành phần biểu đồ Gantt thay vì các tác vụ ban đầu. Trong trình xác thực set, tôi sử dụng đầu ra bảng điều khiển đơn giản để hiển thị rằng tôi đã được thông báo về thay đổi thuộc tính.
Mã:
import VanillaGanttChart from "./VanillaGanttChart.js";var chart = document.querySelector("#g1");var jobs = [ {id: "j1", start: new Date("2021/6/1"), end: new Date("2021/6/4"), resource: 1}, {id: "j2", start: new Date("2021/6/4"), end: new Date("2021/6/13"), resource: 2}, {id: "j3", start: new Date("2021/6/13"), end: new Date("2021/6/21"), resource: 3},];var p_jobs = [];chart.resources = [{id:1, name: "Nhiệm vụ 1"}, {id:2, name: "Nhiệm vụ 2"}, {id:3, name: "Nhiệm vụ 3"}, {id:4, name: "Nhiệm vụ 4"}];jobs.forEach(job => { var validator = { set: function(obj, prop, value) { console.log("Job " + obj.id + ": " + prop + " đã được thay đổi thành " + value); console.log(); obj[prop] = value; return true; }, get: function(obj, prop){ return obj[prop]; } }; var p_job = new Proxy(job, validator); p_jobs.push(p_job);});chart.jobs = p_jobs;

Outlook​

Biểu đồ Gantt là một ví dụ cho thấy cách bạn có thể sử dụng các công nghệ của Web Components, CSS Grid và JavaScript Proxy để phát triển phần tử HTML tùy chỉnh với giao diện đồ họa phức tạp hơn một chút. Bạn được chào đón để phát triển dự án hơn nữa và/hoặc sử dụng nó trong các dự án của riêng bạn cùng với các khuôn khổ JavaScript khác.

Một lần nữa, bạn có thể tìm thấy tất cả các tệp mẫu và hướng dẫn ở đầu bài viết.

Đọc thêm​

  • Regexes Got Good: Lịch sử và tương lai của biểu thức chính quy trong JavaScript
  • Cách xây dựng trang web đa ngôn ngữ với Nuxt.js
  • Truy vấn kiểu chứa CSS có tác dụng gì?
  • Giới thiệu về hoạt ảnh cuộn theo CSS: Dòng thời gian tiến trình cuộn và xem
 
Back
Bên trên