- Added JS Gantt Chart library files including CSS and JS. - Updated existing Gantt chart styles for better visual representation. - Translated month and day names to Polish in the Gantt chart settings. - Implemented Gantt chart initialization in the main view with sample data. - Added a toggle switch for displaying tasks in the calendar. - Enhanced task edit form with a new checkbox for calendar visibility. - Improved the layout of the logged-in user template to include Gantt chart styles and scripts.
399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
class Gantt {
|
|
constructor(staticID, params) {
|
|
this.staticID = staticID;
|
|
this.sidebarHeader = params.sidebarHeader || 'Unused parameter right now';
|
|
this.noDataFoundMessage = params.noDataFoundMessage || 'No data found.';
|
|
this.startTimeAlias = params.startTimeAlias || 'startTime';
|
|
this.endTimeAlias = params.endTimeAlias || 'endTime';
|
|
this.idAlias = params.idAlias || 'id';
|
|
this.rowAlias = params.rowAlias || 'rowTitle';
|
|
this.linkAlias = params.linkAlias;
|
|
this.tooltipAlias = params.tooltipAlias || 'tooltip';
|
|
this.groupBy = params.groupBy ? params.groupBy.split(',').map(group => group.trim()) : [];
|
|
this.groupByAlias = this.groupBy ? (params.groupByAlias ? params.groupByAlias.split(',').map(group => group.trim()) : this.groupBy) : [];
|
|
this.data = {};
|
|
this.rawData = [];
|
|
this.divisionCount = 0;
|
|
this.maxTime;
|
|
this.minTime;
|
|
this.refreshFunction = params.refreshFunction;
|
|
|
|
this.wrapper = document.createElement('div');
|
|
this.wrapper.classList.add('gfb-gantt-wrapper');
|
|
document.getElementById(this.staticID).appendChild(this.wrapper);
|
|
this.refreshData();
|
|
}
|
|
|
|
refreshData() {
|
|
this.rawData = this.refreshFunction();
|
|
this.empty();
|
|
if(this.rawData.length < 1){
|
|
this.noDataFound();
|
|
return;
|
|
}
|
|
this.processData();
|
|
this.render();
|
|
}
|
|
|
|
processData() {
|
|
//Have to reset the divisionCount here. Otherwise it keeps incrementing on the divisionCount of the previous data set.
|
|
this.divisionCount = 0;
|
|
|
|
//Sorts the data by the start time ascending before anything else is done
|
|
//This ensures that the data is rendered in the proper location when we generate the chart.
|
|
let sortedData = this.rawData.sort((a, b) => new Date(a[this.startTimeAlias]) - new Date(b[this.startTimeAlias])),
|
|
groupedData = {};
|
|
for (let dataRow of sortedData) {
|
|
this.groupArray(groupedData, dataRow);
|
|
//console.log(groupedData);
|
|
}
|
|
|
|
this.data = groupedData;
|
|
|
|
//Get the max and min datetimes in the dataset
|
|
//Used to determine the first and last times that are shown in the chart
|
|
let maxTime = this.rawData.reduce((max, curr) => !!curr[this.endTimeAlias] ? max > new Date(curr[this.endTimeAlias]) ? max : new Date(curr[this.endTimeAlias]) : max, new Date(0));
|
|
let minTime = this.rawData.reduce((min, curr) => !!curr[this.startTimeAlias] ? min > new Date(curr[this.startTimeAlias]) ? new Date(curr[this.startTimeAlias]) : min : min, new Date(maxTime.getTime()));
|
|
this.maxTime = roundHourUp(maxTime);
|
|
this.minTime = roundHourDown(minTime);
|
|
|
|
if(this.minTime === this.maxTime)
|
|
return;
|
|
|
|
//Determines the number of columns(times) that will be shown in the chart.
|
|
//Mostly used for iteration later in the render process
|
|
for (let i = new Date(this.minTime.getTime()); i <= this.maxTime; i.setTime(i.getTime() + (60 * 60 * 1000))) {
|
|
this.divisionCount++;
|
|
}
|
|
|
|
}
|
|
//Takes advantage of the fact that javascript is kind of pass-by-reference for object data types
|
|
//So every change to result here is actually a change to our groupedData variable
|
|
groupArray(result, entry, iter = 0) {
|
|
let nextResult;
|
|
if (iter === this.groupBy.length + 1) {
|
|
result.push(entry);
|
|
return;
|
|
}
|
|
|
|
|
|
let groupingProperty = result[entry[this.groupBy[iter]]];
|
|
let chartEntry = result[entry[this.idAlias]];
|
|
|
|
//If we can't find a property within result for the groupBy parameter at this level we create a new one.
|
|
if (!result[entry[this.groupBy[iter]]] && iter < this.groupBy.length) {
|
|
result[entry[this.groupBy[iter]]] = {groupName: entry[this.groupByAlias[iter]]};
|
|
nextResult = result[entry[this.groupBy[iter]]];
|
|
}
|
|
else if (!result[entry[this.idAlias]] && iter === this.groupBy.length) {
|
|
result[entry[this.idAlias]] = [];
|
|
nextResult = result[entry[this.idAlias]];
|
|
}
|
|
else if (result[entry[this.groupBy[iter]]]) {
|
|
nextResult = result[entry[this.groupBy[iter]]];
|
|
}
|
|
else if (result[entry[this.idAlias]]) {
|
|
nextResult = result[entry[this.idAlias]];
|
|
}
|
|
iter++;
|
|
this.groupArray(nextResult, entry, iter);
|
|
}
|
|
|
|
//Creates the header for the Gantt chart
|
|
buildHeader() {
|
|
let headerDivs = `<div class="gfb-gantt-header-spacer"></div>`;
|
|
if (this.divisionCount > 1) {
|
|
for (let i = 0; i < this.divisionCount; i++) {
|
|
let date = new Date(this.minTime.getTime() + ((60 * 60 * 1000) * i)),
|
|
hour = date.getHours().toString().padStart(2, '0'),
|
|
minutes = date.getMinutes().toString().padStart(2, '0');
|
|
headerDivs += `<div class="gfb-gantt-header">${hour}:${minutes}</div>`;
|
|
}
|
|
}
|
|
return `<div class="gfb-gantt-headers" style="grid-template-columns: 100px repeat(${this.divisionCount}, 1fr)">${headerDivs}</div>`;
|
|
}
|
|
|
|
|
|
buildLines() {
|
|
let lines = '<div class="gfb-gantt-sidebar-template"></div>';
|
|
for (let i = 0; i < this.divisionCount; i++) {
|
|
lines += `<div class="gfb-gantt-line"></div>`;
|
|
}
|
|
return `<div class="gfb-gantt-lines-container" style="grid-template-columns: 100px repeat(${this.divisionCount}, 1fr)">${lines}</div>`;
|
|
}
|
|
|
|
buildRow(rowArr, dataIndex) {
|
|
let totalTime = this.maxTime - this.minTime,
|
|
compositeRows = `<div style="grid-column: 2/${this.divisionCount + 1};grid-row:1;display:flex;align-items:center"><div class="gfb-gantt-sub-row-wrapper">`;
|
|
for (let i = 0; i < rowArr.length; i++) {
|
|
//Check to see if the current entry has a start and end time. If not we break
|
|
if (!rowArr[i][this.startTimeAlias] || !rowArr[i][this.startTimeAlias])
|
|
break;
|
|
let currElStart = new Date(rowArr[i][this.startTimeAlias]),
|
|
currElEnd = new Date(rowArr[i][this.endTimeAlias]),
|
|
currElRunPercent = ((currElEnd - currElStart) / totalTime) * 100;
|
|
if (i === 0 || (rowArr[i - 1] && new Date(rowArr[i - 1][this.endTimeAlias]) !== currElStart)) {
|
|
let baseTime = (i === 0 ? this.minTime : new Date(rowArr[i - 1][this.endTimeAlias])),
|
|
difference = ((currElStart - baseTime) / totalTime) * 100;
|
|
compositeRows += `<div style="width:${difference}%;"></div>`;
|
|
}
|
|
//If we don't have a linkAlias we assume the entries are not meant to link anywhere, so we just render them as divs instead.
|
|
if (this.linkAlias)
|
|
compositeRows += `<a class="gfb-gantt-row-entry" style="width:${currElRunPercent}%;" href="${rowArr[i][this.linkAlias]}" data-index="${dataIndex.join('-')}-${i}"></a>`;
|
|
else
|
|
compositeRows += `<div class="gfb-gantt-row-entry" style="width:${currElRunPercent}%;" data-index="${dataIndex.join('-')}-${i}"></div>`;
|
|
}
|
|
return compositeRows + '</div></div>';
|
|
}
|
|
|
|
buildContent() {
|
|
let body = ['<div class="gfb-gantt-row-container">'],
|
|
header = this.buildHeader(),
|
|
this1 = this;
|
|
|
|
buildContent(this.data, body);
|
|
body.push('</div>');
|
|
|
|
return `<div class="gfb-gantt-content">${header}${body.join('')}</div>`;
|
|
|
|
function buildContent(data, result, depth = 0, dataIndex = []) {
|
|
for (let prop in data) {
|
|
if (data[prop]) {
|
|
if(prop !== 'groupName')
|
|
dataIndex.push(prop);
|
|
|
|
if (typeof data[prop] === "object" && !Array.isArray(data[prop])) {
|
|
result.push(`<div class="gfb-gantt-grouping-header" style="padding-left: ${5 + (depth * 20)}px">${data[prop].groupName}</div><div>`);
|
|
depth += 1;
|
|
buildContent(data[prop], result, depth, dataIndex);
|
|
depth -= 1;
|
|
}
|
|
else if (Array.isArray(data[prop])) {
|
|
result.push(`<div class="gfb-gantt-row" style="grid-template-columns: 100px repeat(${this1.divisionCount}, 1fr)"><div class="gfb-gantt-sidebar-header">${data[prop][0][this1.rowAlias]}</div>${this1.buildRow(data[prop], dataIndex)}</div>`);
|
|
}
|
|
dataIndex.pop();
|
|
}
|
|
}
|
|
result.push('</div>');
|
|
}
|
|
|
|
}
|
|
|
|
buildChart() {
|
|
let content = this.buildContent();
|
|
let lines = this.buildLines();
|
|
return `${lines}${content}`;
|
|
}
|
|
|
|
bindHover() {
|
|
let bindElements = document.querySelectorAll(`#${this.staticID} .gfb-gantt-row-entry`);
|
|
|
|
bindElements.forEach(bindElement => {
|
|
let toolTipElement,
|
|
timeout;
|
|
bindElement.addEventListener('mouseover', e => {
|
|
let target = e.target,
|
|
indexArray = target.getAttribute('data-index').split('-'),
|
|
position = getElOffset(target),
|
|
targetHeight = getBoundingRect(target).height,
|
|
this1 = this;
|
|
indexArray.push(this.tooltipAlias);
|
|
position.top += (targetHeight + 5);
|
|
target.classList.add('hovering');
|
|
//Set a delay on the tooltip appearing so that it doesn't immediately appear if the user happens to swipe their mouse across the screen.
|
|
//At the end of the delay we check to see if the mouse is still hovering, at which point we will show the tooltip
|
|
timeout = setTimeout(() => {
|
|
if (!target.classList.contains('hovering'))
|
|
return;
|
|
toolTipElement = document.createElement(`div`);
|
|
toolTipElement.classList.add('gfb-gantt-row-entry-tooltip');
|
|
toolTipElement.innerHTML = getToolTipData();
|
|
toolTipElement.style.top = `${position.top}px`;
|
|
toolTipElement.style.left = `${position.left}px`;
|
|
document.body.appendChild(toolTipElement);
|
|
fadeIn(toolTipElement, 300);
|
|
}, 300);
|
|
|
|
function getToolTipData() {
|
|
let dataArray = this1.data;
|
|
for (let i = 0; i < indexArray.length; i++) {
|
|
if (i === indexArray.length - 1)
|
|
return dataArray[indexArray[i]]
|
|
else
|
|
dataArray = dataArray[indexArray[i]];
|
|
}
|
|
}
|
|
})
|
|
|
|
bindElement.addEventListener('mouseout', e => {
|
|
let target = e.target;
|
|
|
|
clearTimeout(timeout);
|
|
target.classList.remove('hovering');
|
|
if (toolTipElement)
|
|
fadeOut(toolTipElement, 300, () => toolTipElement.remove());
|
|
})
|
|
});
|
|
}
|
|
|
|
bindCollapse(){
|
|
let bindElements = document.querySelectorAll(`#${this.staticID} .gfb-gantt-grouping-header`);
|
|
|
|
bindElements.forEach(bindElement => {
|
|
bindElement.addEventListener('click', e => {
|
|
let group = e.target.nextSibling,
|
|
duration = 300;
|
|
if(!group.classList.contains('gfb-gantt-group--collapsed')){
|
|
slideUp(group, duration);
|
|
group.classList.add('gfb-gantt-group--collapsed');
|
|
}
|
|
else{
|
|
slideDown(group, duration);
|
|
group.classList.remove('gfb-gantt-group--collapsed');
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
render() {
|
|
this.wrapper.innerHTML = this.buildChart();
|
|
|
|
this.bindCollapse();
|
|
|
|
if (this.tooltipAlias)
|
|
this.bindHover();
|
|
}
|
|
|
|
noDataFound() {
|
|
this.wrapper.innerHTML = this.noDataFoundMessage;
|
|
}
|
|
|
|
empty() {
|
|
this.wrapper.innerHTML = '';
|
|
}
|
|
|
|
}
|
|
|
|
function roundHourUp(date) {
|
|
let m = 60 * 60 * 1000
|
|
return new Date(Math.ceil(date.getTime() / m) * m);
|
|
}
|
|
function roundHourDown(date) {
|
|
let m = 60 * 60 * 1000;
|
|
return new Date(Math.floor(date.getTime() / m) * m);
|
|
}
|
|
|
|
function fadeIn(element, ms, callback = null) {
|
|
let duration = ms,
|
|
interval = 50,
|
|
gap = interval / duration,
|
|
opacity = 0;
|
|
|
|
element.style.display = 'block';
|
|
element.style.opacity = opacity;
|
|
|
|
let fading = window.setInterval(fade, interval);
|
|
|
|
function fade() {
|
|
opacity = opacity + gap;
|
|
element.style.opacity = opacity;
|
|
|
|
if (opacity <= 0)
|
|
element.style.display = 'none';
|
|
if (opacity >= 1) {
|
|
window.clearInterval(fading);
|
|
if (callback)
|
|
callback();
|
|
}
|
|
}
|
|
}
|
|
function fadeOut(element, ms, callback = null) {
|
|
let duration = ms,
|
|
interval = 50,
|
|
gap = interval / duration,
|
|
opacity = 1;
|
|
|
|
let fading = window.setInterval(fade, interval);
|
|
|
|
function fade() {
|
|
opacity = opacity - gap;
|
|
element.style.opacity = opacity;
|
|
|
|
if (opacity <= 0)
|
|
element.style.display = 'none';
|
|
if (opacity <= 0) {
|
|
window.clearInterval(fading);
|
|
if (callback)
|
|
callback();
|
|
}
|
|
}
|
|
}
|
|
|
|
function getElOffset(element) {
|
|
let boundingRect = getBoundingRect(element);
|
|
return { top: boundingRect.top + window.scrollY, left: boundingRect.left + window.scrollX };
|
|
}
|
|
function getBoundingRect(element) {
|
|
return element.getBoundingClientRect();
|
|
}
|
|
|
|
function slideUp(element, ms){
|
|
|
|
setProperty(element, 'height', `${element.offsetHeight}px`);
|
|
setProperty(element, 'transition-property', 'height, margin, padding');
|
|
setProperty(element, 'transition-duration', `${ms}ms`);
|
|
setProperty(element, 'box-sizing', 'border-box');
|
|
|
|
zeroMultiProperty(element, ['margin-bottom', 'margin-top', 'padding-top', 'padding-bottom']);
|
|
setProperty(element, 'overflow', 'hidden');
|
|
|
|
//Need a timeout otherwise the slide up animation is not performed
|
|
setTimeout(() => setProperty(element, 'height'), 0);
|
|
|
|
setTimeout(() => {
|
|
setProperty(element, 'display', 'none');
|
|
removeMultiProperty(element, ['height', 'padding-top', 'padding-bottom', 'margin-top', 'margin-bottom', 'overflow', 'transition-duration', 'transition-property'])
|
|
}, ms);
|
|
}
|
|
function slideDown(element, ms){
|
|
|
|
element.style.display = 'block';
|
|
|
|
let height = element.offsetHeight;
|
|
|
|
setProperty(element, 'height');
|
|
setProperty(element, 'transition-property', 'height, margin, padding');
|
|
setProperty(element, 'transition-duration', `${ms}ms`);
|
|
setProperty(element, 'box-sizing', 'border-box');
|
|
setProperty(element, 'overflow', 'hidden');
|
|
|
|
//Need a timeout otherwise the slide up animation is not performed
|
|
setTimeout(() => setProperty(element, 'height', `${height}px`), 0);
|
|
|
|
setTimeout(() => {
|
|
removeMultiProperty(element, ['height', 'overflow', 'transition-duration', 'transition-property']);
|
|
}, ms);
|
|
}
|
|
|
|
//Sets the inline property of the given element to the value provided.
|
|
//If not provided, the value is set to 0
|
|
function setProperty(element, property, value = 0) {
|
|
element.style.setProperty(property, value);
|
|
}
|
|
|
|
//Iteratively calls setProperty
|
|
function zeroMultiProperty(element, properties) {
|
|
for (property of properties) {
|
|
setProperty(element, property);
|
|
}
|
|
}
|
|
|
|
//Iteratively calls removeProperty
|
|
function removeMultiProperty(element, properties){
|
|
for(property of properties)
|
|
removeProperty(element, property);
|
|
}
|
|
//Removes the given property from the given element's inline styling
|
|
function removeProperty(element, property){
|
|
element.style.removeProperty(property);
|
|
}; |