deprecated-oc-search/static/js/gantt/frappe-gantt.js
2023-08-26 22:04:56 +02:00

1963 lines
57 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var Gantt = (function () {
'use strict';
const YEAR = 'year';
const MONTH = 'month';
const DAY = 'day';
const HOUR = 'hour';
const MINUTE = 'minute';
const SECOND = 'second';
const MILLISECOND = 'millisecond';
const month_names = {
en: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
],
es: [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre'
],
ru: [
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь'
],
ptBr: [
'Janeiro',
'Fevereiro',
'Março',
'Abril',
'Maio',
'Junho',
'Julho',
'Agosto',
'Setembro',
'Outubro',
'Novembro',
'Dezembro'
],
fr: [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
],
tr: [
'Ocak',
'Şubat',
'Mart',
'Nisan',
'Mayıs',
'Haziran',
'Temmuz',
'Ağustos',
'Eylül',
'Ekim',
'Kasım',
'Aralık'
],
zh: [
'一月',
'二月',
'三月',
'四月',
'五月',
'六月',
'七月',
'八月',
'九月',
'十月',
'十一月',
'十二月'
]
};
var date_utils = {
parse(date, date_separator = '-', time_separator = /[.:]/) {
if (date instanceof Date) {
return date;
}
if (typeof date === 'string') {
let date_parts, time_parts;
const parts = date.split(' ');
date_parts = parts[0]
.split(date_separator)
.map(val => parseInt(val, 10));
time_parts = parts[1] && parts[1].split(time_separator);
// month is 0 indexed
date_parts[1] = date_parts[1] - 1;
let vals = date_parts;
if (time_parts && time_parts.length) {
if (time_parts.length == 4) {
time_parts[3] = '0.' + time_parts[3];
time_parts[3] = parseFloat(time_parts[3]) * 1000;
}
vals = vals.concat(time_parts);
}
return new Date(...vals);
}
},
to_string(date, with_time = false) {
if (!(date instanceof Date)) {
throw new TypeError('Invalid argument type');
}
const vals = this.get_date_values(date).map((val, i) => {
if (i === 1) {
// add 1 for month
val = val + 1;
}
if (i === 6) {
return padStart(val + '', 3, '0');
}
return padStart(val + '', 2, '0');
});
const date_string = `${vals[0]}-${vals[1]}-${vals[2]}`;
const time_string = `${vals[3]}:${vals[4]}:${vals[5]}.${vals[6]}`;
return date_string + (with_time ? ' ' + time_string : '');
},
format(date, format_string = 'YYYY-MM-DD HH:mm:ss.SSS', lang = 'en') {
const values = this.get_date_values(date).map(d => padStart(d, 2, 0));
const format_map = {
YYYY: values[0],
MM: padStart(+values[1] + 1, 2, 0),
DD: values[2],
HH: values[3],
mm: values[4],
ss: values[5],
SSS:values[6],
D: values[2],
MMMM: month_names[lang][+values[1]],
MMM: month_names[lang][+values[1]]
};
let str = format_string;
const formatted_values = [];
Object.keys(format_map)
.sort((a, b) => b.length - a.length) // big string first
.forEach(key => {
if (str.includes(key)) {
str = str.replace(key, `$${formatted_values.length}`);
formatted_values.push(format_map[key]);
}
});
formatted_values.forEach((value, i) => {
str = str.replace(`$${i}`, value);
});
return str;
},
diff(date_a, date_b, scale = DAY) {
let milliseconds, seconds, hours, minutes, days, months, years;
milliseconds = date_a - date_b;
seconds = milliseconds / 1000;
minutes = seconds / 60;
hours = minutes / 60;
days = hours / 24;
months = days / 30;
years = months / 12;
if (!scale.endsWith('s')) {
scale += 's';
}
return Math.floor(
{
milliseconds,
seconds,
minutes,
hours,
days,
months,
years
}[scale]
);
},
today() {
const vals = this.get_date_values(new Date()).slice(0, 3);
return new Date(...vals);
},
now() {
return new Date();
},
add(date, qty, scale) {
qty = parseInt(qty, 10);
const vals = [
date.getFullYear() + (scale === YEAR ? qty : 0),
date.getMonth() + (scale === MONTH ? qty : 0),
date.getDate() + (scale === DAY ? qty : 0),
date.getHours() + (scale === HOUR ? qty : 0),
date.getMinutes() + (scale === MINUTE ? qty : 0),
date.getSeconds() + (scale === SECOND ? qty : 0),
date.getMilliseconds() + (scale === MILLISECOND ? qty : 0)
];
return new Date(...vals);
},
start_of(date, scale) {
const scores = {
[YEAR]: 6,
[MONTH]: 5,
[DAY]: 4,
[HOUR]: 3,
[MINUTE]: 2,
[SECOND]: 1,
[MILLISECOND]: 0
};
function should_reset(_scale) {
const max_score = scores[scale];
return scores[_scale] <= max_score;
}
const vals = [
date.getFullYear(),
should_reset(YEAR) ? 0 : date.getMonth(),
should_reset(MONTH) ? 1 : date.getDate(),
should_reset(DAY) ? 0 : date.getHours(),
should_reset(HOUR) ? 0 : date.getMinutes(),
should_reset(MINUTE) ? 0 : date.getSeconds(),
should_reset(SECOND) ? 0 : date.getMilliseconds()
];
return new Date(...vals);
},
clone(date) {
return new Date(...this.get_date_values(date));
},
get_date_values(date) {
return [
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds()
];
},
get_days_in_month(date) {
const no_of_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const month = date.getMonth();
if (month !== 1) {
return no_of_days[month];
}
// Feb
const year = date.getFullYear();
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
return 29;
}
return 28;
}
};
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
function padStart(str, targetLength, padString) {
str = str + '';
targetLength = targetLength >> 0;
padString = String(typeof padString !== 'undefined' ? padString : ' ');
if (str.length > targetLength) {
return String(str);
} else {
targetLength = targetLength - str.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length);
}
return padString.slice(0, targetLength) + String(str);
}
}
function $(expr, con) {
return typeof expr === 'string'
? (con || document).querySelector(expr)
: expr || null;
}
function createSVG(tag, attrs) {
const elem = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (let attr in attrs) {
if (attr === 'append_to') {
const parent = attrs.append_to;
parent.appendChild(elem);
} else if (attr === 'innerHTML') {
elem.innerHTML = attrs.innerHTML;
} else {
elem.setAttribute(attr, attrs[attr]);
}
}
return elem;
}
function animateSVG(svgElement, attr, from, to) {
const animatedSvgElement = getAnimationElement(svgElement, attr, from, to);
if (animatedSvgElement === svgElement) {
// triggered 2nd time programmatically
// trigger artificial click event
const event = document.createEvent('HTMLEvents');
event.initEvent('click', true, true);
event.eventName = 'click';
animatedSvgElement.dispatchEvent(event);
}
}
function getAnimationElement(
svgElement,
attr,
from,
to,
dur = '0.4s',
begin = '0.1s'
) {
const animEl = svgElement.querySelector('animate');
if (animEl) {
$.attr(animEl, {
attributeName: attr,
from,
to,
dur,
begin: 'click + ' + begin // artificial click
});
return svgElement;
}
const animateElement = createSVG('animate', {
attributeName: attr,
from,
to,
dur,
begin,
calcMode: 'spline',
values: from + ';' + to,
keyTimes: '0; 1',
keySplines: cubic_bezier('ease-out')
});
svgElement.appendChild(animateElement);
return svgElement;
}
function cubic_bezier(name) {
return {
ease: '.25 .1 .25 1',
linear: '0 0 1 1',
'ease-in': '.42 0 1 1',
'ease-out': '0 0 .58 1',
'ease-in-out': '.42 0 .58 1'
}[name];
}
$.on = (element, event, selector, callback) => {
if (!callback) {
callback = selector;
$.bind(element, event, callback);
} else {
$.delegate(element, event, selector, callback);
}
};
$.off = (element, event, handler) => {
element.removeEventListener(event, handler);
};
$.bind = (element, event, callback) => {
event.split(/\s+/).forEach(function(event) {
element.addEventListener(event, callback);
});
};
$.delegate = (element, event, selector, callback) => {
element.addEventListener(event, function(e) {
const delegatedTarget = e.target.closest(selector);
if (delegatedTarget) {
e.delegatedTarget = delegatedTarget;
callback.call(this, e, delegatedTarget);
}
});
};
$.closest = (selector, element) => {
if (!element) return null;
if (element.matches(selector)) {
return element;
}
return $.closest(selector, element.parentNode);
};
$.attr = (element, attr, value) => {
if (!value && typeof attr === 'string') {
return element.getAttribute(attr);
}
if (typeof attr === 'object') {
for (let key in attr) {
$.attr(element, key, attr[key]);
}
return;
}
element.setAttribute(attr, value);
};
class Bar {
constructor(gantt, task) {
this.set_defaults(gantt, task);
this.prepare();
this.draw();
this.bind();
}
set_defaults(gantt, task) {
this.action_completed = false;
this.gantt = gantt;
this.task = task;
}
prepare() {
this.prepare_values();
this.prepare_helpers();
}
prepare_values() {
this.invalid = this.task.invalid;
this.height = this.gantt.options.bar_height;
this.x = this.compute_x();
this.y = this.compute_y();
this.corner_radius = this.gantt.options.bar_corner_radius;
this.duration =
date_utils.diff(this.task._end, this.task._start, 'hour') /
this.gantt.options.step;
this.width = this.gantt.options.column_width * this.duration;
this.progress_width =
this.gantt.options.column_width *
this.duration *
(this.task.progress / 100) || 0;
this.group = createSVG('g', {
class: 'bar-wrapper ' + (this.task.custom_class || ''),
'data-id': this.task.id
});
this.bar_group = createSVG('g', {
class: 'bar-group',
append_to: this.group
});
this.handle_group = createSVG('g', {
class: 'handle-group',
append_to: this.group
});
}
prepare_helpers() {
SVGElement.prototype.getX = function() {
return +this.getAttribute('x');
};
SVGElement.prototype.getY = function() {
return +this.getAttribute('y');
};
SVGElement.prototype.getWidth = function() {
return +this.getAttribute('width');
};
SVGElement.prototype.getHeight = function() {
return +this.getAttribute('height');
};
SVGElement.prototype.getEndX = function() {
return this.getX() + this.getWidth();
};
}
draw() {
this.draw_bar();
this.draw_progress_bar();
this.draw_label();
this.draw_resize_handles();
}
draw_bar() {
this.$bar = createSVG('rect', {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rx: this.corner_radius,
ry: this.corner_radius,
class: 'bar',
append_to: this.bar_group
});
animateSVG(this.$bar, 'width', 0, this.width);
if (this.invalid) {
this.$bar.classList.add('bar-invalid');
}
}
draw_progress_bar() {
if (this.invalid) return;
this.$bar_progress = createSVG('rect', {
x: this.x,
y: this.y,
width: this.progress_width,
height: this.height,
rx: this.corner_radius,
ry: this.corner_radius,
class: 'bar-progress',
append_to: this.bar_group
});
animateSVG(this.$bar_progress, 'width', 0, this.progress_width);
}
draw_label() {
createSVG('text', {
x: this.x + this.width / 2,
y: this.y + this.height / 2,
innerHTML: this.task.name,
class: 'bar-label',
append_to: this.bar_group
});
// labels get BBox in the next tick
requestAnimationFrame(() => this.update_label_position());
}
draw_resize_handles() {
if (this.invalid) return;
const bar = this.$bar;
const handle_width = 8;
createSVG('rect', {
x: bar.getX() + bar.getWidth() - 9,
y: bar.getY() + 1,
width: handle_width,
height: this.height - 2,
rx: this.corner_radius,
ry: this.corner_radius,
class: 'handle right',
append_to: this.handle_group
});
createSVG('rect', {
x: bar.getX() + 1,
y: bar.getY() + 1,
width: handle_width,
height: this.height - 2,
rx: this.corner_radius,
ry: this.corner_radius,
class: 'handle left',
append_to: this.handle_group
});
if (this.task.progress && this.task.progress < 100) {
this.$handle_progress = createSVG('polygon', {
points: this.get_progress_polygon_points().join(','),
class: 'handle progress',
append_to: this.handle_group
});
}
}
get_progress_polygon_points() {
const bar_progress = this.$bar_progress;
return [
bar_progress.getEndX() - 5,
bar_progress.getY() + bar_progress.getHeight(),
bar_progress.getEndX() + 5,
bar_progress.getY() + bar_progress.getHeight(),
bar_progress.getEndX(),
bar_progress.getY() + bar_progress.getHeight() - 8.66
];
}
bind() {
if (this.invalid) return;
this.setup_click_event();
}
setup_click_event() {
$.on(this.group, 'focus ' + this.gantt.options.popup_trigger, e => {
if (this.action_completed) {
// just finished a move action, wait for a few seconds
return;
}
this.show_popup();
this.gantt.unselect_all();
this.group.classList.add('active');
});
$.on(this.group, 'dblclick', e => {
if (this.action_completed) {
// just finished a move action, wait for a few seconds
return;
}
this.gantt.trigger_event('click', [this.task]);
});
}
show_popup() {
if (this.gantt.bar_being_dragged) return;
const start_date = date_utils.format(this.task._start, 'MMM D', this.gantt.options.language);
const end_date = date_utils.format(
date_utils.add(this.task._end, -1, 'second'),
'MMM D',
this.gantt.options.language
);
const subtitle = start_date + ' - ' + end_date;
this.gantt.show_popup({
target_element: this.$bar,
title: this.task.name,
subtitle: subtitle,
task: this.task,
});
}
update_bar_position({ x = null, width = null }) {
const bar = this.$bar;
if (x) {
// get all x values of parent task
const xs = this.task.dependencies.map(dep => {
return this.gantt.get_bar(dep).$bar.getX();
});
// child task must not go before parent
const valid_x = xs.reduce((prev, curr) => {
return x >= curr;
}, x);
if (!valid_x) {
width = null;
return;
}
this.update_attr(bar, 'x', x);
}
if (width && width >= this.gantt.options.column_width) {
this.update_attr(bar, 'width', width);
}
this.update_label_position();
this.update_handle_position();
this.update_progressbar_position();
this.update_arrow_position();
}
date_changed() {
let changed = false;
const { new_start_date, new_end_date } = this.compute_start_end_date();
if (Number(this.task._start) !== Number(new_start_date)) {
changed = true;
this.task._start = new_start_date;
}
if (Number(this.task._end) !== Number(new_end_date)) {
changed = true;
this.task._end = new_end_date;
}
if (!changed) return;
this.gantt.trigger_event('date_change', [
this.task,
new_start_date,
date_utils.add(new_end_date, -1, 'second')
]);
}
progress_changed() {
const new_progress = this.compute_progress();
this.task.progress = new_progress;
this.gantt.trigger_event('progress_change', [this.task, new_progress]);
}
set_action_completed() {
this.action_completed = true;
setTimeout(() => (this.action_completed = false), 1000);
}
compute_start_end_date() {
const bar = this.$bar;
const x_in_units = bar.getX() / this.gantt.options.column_width;
const new_start_date = date_utils.add(
this.gantt.gantt_start,
x_in_units * this.gantt.options.step,
'hour'
);
const width_in_units = bar.getWidth() / this.gantt.options.column_width;
const new_end_date = date_utils.add(
new_start_date,
width_in_units * this.gantt.options.step,
'hour'
);
return { new_start_date, new_end_date };
}
compute_progress() {
const progress =
this.$bar_progress.getWidth() / this.$bar.getWidth() * 100;
return parseInt(progress, 10);
}
compute_x() {
const { step, column_width } = this.gantt.options;
const task_start = this.task._start;
const gantt_start = this.gantt.gantt_start;
const diff = date_utils.diff(task_start, gantt_start, 'hour');
let x = diff / step * column_width;
if (this.gantt.view_is('Month')) {
const diff = date_utils.diff(task_start, gantt_start, 'day');
x = diff * column_width / 30;
}
return x;
}
compute_y() {
return (
this.gantt.options.header_height +
this.gantt.options.padding +
this.task._index * (this.height + this.gantt.options.padding)
);
}
get_snap_position(dx) {
let odx = dx,
rem,
position;
if (this.gantt.view_is('Week')) {
rem = dx % (this.gantt.options.column_width / 7);
position =
odx -
rem +
(rem < this.gantt.options.column_width / 14
? 0
: this.gantt.options.column_width / 7);
} else if (this.gantt.view_is('Month')) {
rem = dx % (this.gantt.options.column_width / 30);
position =
odx -
rem +
(rem < this.gantt.options.column_width / 60
? 0
: this.gantt.options.column_width / 30);
} else {
rem = dx % this.gantt.options.column_width;
position =
odx -
rem +
(rem < this.gantt.options.column_width / 2
? 0
: this.gantt.options.column_width);
}
return position;
}
update_attr(element, attr, value) {
value = +value;
if (!isNaN(value)) {
element.setAttribute(attr, value);
}
return element;
}
update_progressbar_position() {
this.$bar_progress.setAttribute('x', this.$bar.getX());
this.$bar_progress.setAttribute(
'width',
this.$bar.getWidth() * (this.task.progress / 100)
);
}
update_label_position() {
const bar = this.$bar,
label = this.group.querySelector('.bar-label');
if (label.getBBox().width > bar.getWidth()) {
label.classList.add('big');
label.setAttribute('x', bar.getX() + bar.getWidth() + 5);
} else {
label.classList.remove('big');
label.setAttribute('x', bar.getX() + bar.getWidth() / 2);
}
}
update_handle_position() {
const bar = this.$bar;
this.handle_group
.querySelector('.handle.left')
.setAttribute('x', bar.getX() + 1);
this.handle_group
.querySelector('.handle.right')
.setAttribute('x', bar.getEndX() - 9);
const handle = this.group.querySelector('.handle.progress');
handle &&
handle.setAttribute('points', this.get_progress_polygon_points());
}
update_arrow_position() {
this.arrows = this.arrows || [];
for (let arrow of this.arrows) {
arrow.update();
}
}
}
class Arrow {
constructor(gantt, from_task, to_task) {
this.gantt = gantt;
this.from_task = from_task;
this.to_task = to_task;
this.calculate_path();
this.draw();
}
calculate_path() {
let start_x =
this.from_task.$bar.getX() + this.from_task.$bar.getWidth() / 2;
const condition = () =>
this.to_task.$bar.getX() < start_x + this.gantt.options.padding &&
start_x > this.from_task.$bar.getX() + this.gantt.options.padding;
while (condition()) {
start_x -= 10;
}
const start_y =
this.gantt.options.header_height +
this.gantt.options.bar_height +
(this.gantt.options.padding + this.gantt.options.bar_height) *
this.from_task.task._index +
this.gantt.options.padding;
const end_x = this.to_task.$bar.getX() - this.gantt.options.padding / 2;
const end_y =
this.gantt.options.header_height +
this.gantt.options.bar_height / 2 +
(this.gantt.options.padding + this.gantt.options.bar_height) *
this.to_task.task._index +
this.gantt.options.padding;
const from_is_below_to =
this.from_task.task._index > this.to_task.task._index;
const curve = this.gantt.options.arrow_curve;
const clockwise = from_is_below_to ? 1 : 0;
const curve_y = from_is_below_to ? -curve : curve;
const offset = from_is_below_to
? end_y + this.gantt.options.arrow_curve
: end_y - this.gantt.options.arrow_curve;
this.path = `
M ${start_x} ${start_y}
V ${offset}
a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve_y}
L ${end_x} ${end_y}
m -5 -5
l 5 5
l -5 5`;
if (
this.to_task.$bar.getX() <
this.from_task.$bar.getX() + this.gantt.options.padding
) {
const down_1 = this.gantt.options.padding / 2 - curve;
const down_2 =
this.to_task.$bar.getY() +
this.to_task.$bar.getHeight() / 2 -
curve_y;
const left = this.to_task.$bar.getX() - this.gantt.options.padding;
this.path = `
M ${start_x} ${start_y}
v ${down_1}
a ${curve} ${curve} 0 0 1 -${curve} ${curve}
H ${left}
a ${curve} ${curve} 0 0 ${clockwise} -${curve} ${curve_y}
V ${down_2}
a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve_y}
L ${end_x} ${end_y}
m -5 -5
l 5 5
l -5 5`;
}
}
draw() {
this.element = createSVG('path', {
d: this.path,
'data-from': this.from_task.task.id,
'data-to': this.to_task.task.id
});
}
update() {
this.calculate_path();
this.element.setAttribute('d', this.path);
}
}
class Popup {
constructor(parent, custom_html) {
this.parent = parent;
this.custom_html = custom_html;
this.make();
}
make() {
this.parent.innerHTML = `
<div class="title"></div>
<div class="subtitle"></div>
<div class="pointer"></div>
`;
this.hide();
this.title = this.parent.querySelector('.title');
this.subtitle = this.parent.querySelector('.subtitle');
this.pointer = this.parent.querySelector('.pointer');
}
show(options) {
if (!options.target_element) {
throw new Error('target_element is required to show popup');
}
if (!options.position) {
options.position = 'left';
}
const target_element = options.target_element;
if (this.custom_html) {
let html = this.custom_html(options.task);
html += '<div class="pointer"></div>';
this.parent.innerHTML = html;
this.pointer = this.parent.querySelector('.pointer');
} else {
// set data
this.title.innerHTML = options.title;
this.subtitle.innerHTML = options.subtitle;
this.parent.style.width = this.parent.clientWidth + 'px';
}
// set position
let position_meta;
if (target_element instanceof HTMLElement) {
position_meta = target_element.getBoundingClientRect();
} else if (target_element instanceof SVGElement) {
position_meta = options.target_element.getBBox();
}
if (options.position === 'left') {
this.parent.style.left =
position_meta.x + (position_meta.width + 10) + 'px';
this.parent.style.top = position_meta.y + 'px';
this.pointer.style.transform = 'rotateZ(90deg)';
this.pointer.style.left = '-7px';
this.pointer.style.top = '2px';
}
// show
this.parent.style.opacity = 1;
}
hide() {
this.parent.style.opacity = 0;
}
}
const VIEW_MODE = {
QUARTER_DAY: 'Quarter Day',
HALF_DAY: 'Half Day',
DAY: 'Day',
WEEK: 'Week',
MONTH: 'Month',
YEAR: 'Year'
};
class Gantt {
constructor(wrapper, tasks, options) {
this.setup_wrapper(wrapper);
this.setup_options(options);
this.setup_tasks(tasks);
// initialize with default view mode
this.change_view_mode();
this.bind_events();
}
setup_wrapper(element) {
let svg_element, wrapper_element;
// CSS Selector is passed
if (typeof element === 'string') {
element = document.querySelector(element);
}
// get the SVGElement
if (element instanceof HTMLElement) {
wrapper_element = element;
svg_element = element.querySelector('svg');
} else if (element instanceof SVGElement) {
svg_element = element;
} else {
throw new TypeError(
'Frappé Gantt only supports usage of a string CSS selector,' +
" HTML DOM element or SVG DOM element for the 'element' parameter"
);
}
// svg element
if (!svg_element) {
// create it
this.$svg = createSVG('svg', {
append_to: wrapper_element,
class: 'gantt'
});
} else {
this.$svg = svg_element;
this.$svg.classList.add('gantt');
}
// wrapper element
this.$container = document.createElement('div');
this.$container.classList.add('gantt-container');
const parent_element = this.$svg.parentElement;
parent_element.appendChild(this.$container);
this.$container.appendChild(this.$svg);
// popup wrapper
this.popup_wrapper = document.createElement('div');
this.popup_wrapper.classList.add('popup-wrapper');
this.$container.appendChild(this.popup_wrapper);
}
setup_options(options) {
const default_options = {
header_height: 50,
column_width: 30,
step: 24,
view_modes: [...Object.values(VIEW_MODE)],
bar_height: 20,
bar_corner_radius: 3,
arrow_curve: 5,
padding: 18,
view_mode: 'Day',
date_format: 'YYYY-MM-DD',
popup_trigger: 'click',
custom_popup_html: null,
language: 'en'
};
this.options = Object.assign({}, default_options, options);
}
setup_tasks(tasks) {
// prepare tasks
this.tasks = tasks.map((task, i) => {
// convert to Date objects
task._start = date_utils.parse(task.start);
task._end = date_utils.parse(task.end);
// make task invalid if duration too large
if (date_utils.diff(task._end, task._start, 'year') > 10) {
task.end = null;
}
// cache index
task._index = i;
// invalid dates
if (!task.start && !task.end) {
const today = date_utils.today();
task._start = today;
task._end = date_utils.add(today, 2, 'day');
}
if (!task.start && task.end) {
task._start = date_utils.add(task._end, -2, 'day');
}
if (task.start && !task.end) {
task._end = date_utils.add(task._start, 2, 'day');
}
// if hours is not set, assume the last day is full day
// e.g: 2018-09-09 becomes 2018-09-09 23:59:59
const task_end_values = date_utils.get_date_values(task._end);
if (task_end_values.slice(3).every(d => d === 0)) {
task._end = date_utils.add(task._end, 24, 'hour');
}
// invalid flag
if (!task.start || !task.end) {
task.invalid = true;
}
// dependencies
if (typeof task.dependencies === 'string' || !task.dependencies) {
let deps = [];
if (task.dependencies) {
deps = task.dependencies
.split(',')
.map(d => d.trim())
.filter(d => d);
}
task.dependencies = deps;
}
// uids
if (!task.id) {
task.id = generate_id(task);
}
return task;
});
this.setup_dependencies();
}
setup_dependencies() {
this.dependency_map = {};
for (let t of this.tasks) {
for (let d of t.dependencies) {
this.dependency_map[d] = this.dependency_map[d] || [];
this.dependency_map[d].push(t.id);
}
}
}
refresh(tasks) {
this.setup_tasks(tasks);
this.change_view_mode();
}
change_view_mode(mode = this.options.view_mode) {
this.update_view_scale(mode);
this.setup_dates();
this.render();
// fire viewmode_change event
this.trigger_event('view_change', [mode]);
}
update_view_scale(view_mode) {
this.options.view_mode = view_mode;
if (view_mode === VIEW_MODE.DAY) {
this.options.step = 24;
this.options.column_width = 38;
} else if (view_mode === VIEW_MODE.HALF_DAY) {
this.options.step = 24 / 2;
this.options.column_width = 38;
} else if (view_mode === VIEW_MODE.QUARTER_DAY) {
this.options.step = 24 / 4;
this.options.column_width = 38;
} else if (view_mode === VIEW_MODE.WEEK) {
this.options.step = 24 * 7;
this.options.column_width = 140;
} else if (view_mode === VIEW_MODE.MONTH) {
this.options.step = 24 * 30;
this.options.column_width = 120;
} else if (view_mode === VIEW_MODE.YEAR) {
this.options.step = 24 * 365;
this.options.column_width = 120;
}
}
setup_dates() {
this.setup_gantt_dates();
this.setup_date_values();
}
setup_gantt_dates() {
this.gantt_start = this.gantt_end = null;
for (let task of this.tasks) {
// set global start and end date
if (!this.gantt_start || task._start < this.gantt_start) {
this.gantt_start = task._start;
}
if (!this.gantt_end || task._end > this.gantt_end) {
this.gantt_end = task._end;
}
}
this.gantt_start = date_utils.start_of(this.gantt_start, 'day');
this.gantt_end = date_utils.start_of(this.gantt_end, 'day');
// add date padding on both sides
if (this.view_is([VIEW_MODE.QUARTER_DAY, VIEW_MODE.HALF_DAY])) {
this.gantt_start = date_utils.add(this.gantt_start, -7, 'day');
this.gantt_end = date_utils.add(this.gantt_end, 7, 'day');
} else if (this.view_is(VIEW_MODE.MONTH)) {
this.gantt_start = date_utils.start_of(this.gantt_start, 'year');
this.gantt_end = date_utils.add(this.gantt_end, 1, 'year');
} else if (this.view_is(VIEW_MODE.YEAR)) {
this.gantt_start = date_utils.add(this.gantt_start, -2, 'year');
this.gantt_end = date_utils.add(this.gantt_end, 2, 'year');
} else {
this.gantt_start = date_utils.add(this.gantt_start, -1, 'month');
this.gantt_end = date_utils.add(this.gantt_end, 1, 'month');
}
}
setup_date_values() {
this.dates = [];
let cur_date = null;
while (cur_date === null || cur_date < this.gantt_end) {
if (!cur_date) {
cur_date = date_utils.clone(this.gantt_start);
} else {
if (this.view_is(VIEW_MODE.YEAR)) {
cur_date = date_utils.add(cur_date, 1, 'year');
} else if (this.view_is(VIEW_MODE.MONTH)) {
cur_date = date_utils.add(cur_date, 1, 'month');
} else {
cur_date = date_utils.add(
cur_date,
this.options.step,
'hour'
);
}
}
this.dates.push(cur_date);
}
}
bind_events() {
this.bind_grid_click();
this.bind_bar_events();
}
render() {
this.clear();
this.setup_layers();
this.make_grid();
this.make_dates();
this.make_bars();
this.make_arrows();
this.map_arrows_on_bars();
this.set_width();
this.set_scroll_position();
}
setup_layers() {
this.layers = {};
const layers = ['grid', 'date', 'arrow', 'progress', 'bar', 'details'];
// make group layers
for (let layer of layers) {
this.layers[layer] = createSVG('g', {
class: layer,
append_to: this.$svg
});
}
}
make_grid() {
this.make_grid_background();
this.make_grid_rows();
this.make_grid_header();
this.make_grid_ticks();
this.make_grid_highlights();
}
make_grid_background() {
const grid_width = this.dates.length * this.options.column_width;
const grid_height =
this.options.header_height +
this.options.padding +
(this.options.bar_height + this.options.padding) *
this.tasks.length;
createSVG('rect', {
x: 0,
y: 0,
width: grid_width,
height: grid_height,
class: 'grid-background',
append_to: this.layers.grid
});
$.attr(this.$svg, {
height: grid_height + this.options.padding + 100,
width: '100%'
});
}
make_grid_rows() {
const rows_layer = createSVG('g', { append_to: this.layers.grid });
const lines_layer = createSVG('g', { append_to: this.layers.grid });
const row_width = this.dates.length * this.options.column_width;
const row_height = this.options.bar_height + this.options.padding;
let row_y = this.options.header_height + this.options.padding / 2;
for (let task of this.tasks) {
createSVG('rect', {
x: 0,
y: row_y,
width: row_width,
height: row_height,
class: 'grid-row',
append_to: rows_layer
});
createSVG('line', {
x1: 0,
y1: row_y + row_height,
x2: row_width,
y2: row_y + row_height,
class: 'row-line',
append_to: lines_layer
});
row_y += this.options.bar_height + this.options.padding;
}
}
make_grid_header() {
const header_width = this.dates.length * this.options.column_width;
const header_height = this.options.header_height + 10;
createSVG('rect', {
x: 0,
y: 0,
width: header_width,
height: header_height,
class: 'grid-header',
append_to: this.layers.grid
});
}
make_grid_ticks() {
let tick_x = 0;
let tick_y = this.options.header_height + this.options.padding / 2;
let tick_height =
(this.options.bar_height + this.options.padding) *
this.tasks.length;
for (let date of this.dates) {
let tick_class = 'tick';
// thick tick for monday
if (this.view_is(VIEW_MODE.DAY) && date.getDate() === 1) {
tick_class += ' thick';
}
// thick tick for first week
if (
this.view_is(VIEW_MODE.WEEK) &&
date.getDate() >= 1 &&
date.getDate() < 8
) {
tick_class += ' thick';
}
// thick ticks for quarters
if (this.view_is(VIEW_MODE.MONTH) && (date.getMonth() + 1) % 3 === 0) {
tick_class += ' thick';
}
createSVG('path', {
d: `M ${tick_x} ${tick_y} v ${tick_height}`,
class: tick_class,
append_to: this.layers.grid
});
if (this.view_is(VIEW_MODE.MONTH)) {
tick_x +=
date_utils.get_days_in_month(date) *
this.options.column_width /
30;
} else {
tick_x += this.options.column_width;
}
}
}
make_grid_highlights() {
// highlight today's date
if (this.view_is(VIEW_MODE.DAY)) {
const x =
date_utils.diff(date_utils.today(), this.gantt_start, 'hour') /
this.options.step *
this.options.column_width;
const y = 0;
const width = this.options.column_width;
const height =
(this.options.bar_height + this.options.padding) *
this.tasks.length +
this.options.header_height +
this.options.padding / 2;
createSVG('rect', {
x,
y,
width,
height,
class: 'today-highlight',
append_to: this.layers.grid
});
}
}
make_dates() {
for (let date of this.get_dates_to_draw()) {
createSVG('text', {
x: date.lower_x,
y: date.lower_y,
innerHTML: date.lower_text,
class: 'lower-text',
append_to: this.layers.date
});
if (date.upper_text) {
const $upper_text = createSVG('text', {
x: date.upper_x,
y: date.upper_y,
innerHTML: date.upper_text,
class: 'upper-text',
append_to: this.layers.date
});
// remove out-of-bound dates
if (
$upper_text.getBBox().x2 > this.layers.grid.getBBox().width
) {
$upper_text.remove();
}
}
}
}
get_dates_to_draw() {
let last_date = null;
const dates = this.dates.map((date, i) => {
const d = this.get_date_info(date, last_date, i);
last_date = date;
return d;
});
return dates;
}
get_date_info(date, last_date, i) {
if (!last_date) {
last_date = date_utils.add(date, 1, 'year');
}
const date_text = {
'Quarter Day_lower': date_utils.format(
date,
'HH',
this.options.language
),
'Half Day_lower': date_utils.format(
date,
'HH',
this.options.language
),
Day_lower:
date.getDate() !== last_date.getDate()
? date_utils.format(date, 'D', this.options.language)
: '',
Week_lower:
date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'D MMM', this.options.language)
: date_utils.format(date, 'D', this.options.language),
Month_lower: date_utils.format(date, 'MMMM', this.options.language),
Year_lower: date_utils.format(date, 'YYYY', this.options.language),
'Quarter Day_upper':
date.getDate() !== last_date.getDate()
? date_utils.format(date, 'D MMM', this.options.language)
: '',
'Half Day_upper':
date.getDate() !== last_date.getDate()
? date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'D MMM', this.options.language)
: date_utils.format(date, 'D', this.options.language)
: '',
Day_upper:
date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'MMMM', this.options.language)
: '',
Week_upper:
date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'MMMM', this.options.language)
: '',
Month_upper:
date.getFullYear() !== last_date.getFullYear()
? date_utils.format(date, 'YYYY', this.options.language)
: '',
Year_upper:
date.getFullYear() !== last_date.getFullYear()
? date_utils.format(date, 'YYYY', this.options.language)
: ''
};
const base_pos = {
x: i * this.options.column_width,
lower_y: this.options.header_height,
upper_y: this.options.header_height - 25
};
const x_pos = {
'Quarter Day_lower': this.options.column_width * 4 / 2,
'Quarter Day_upper': 0,
'Half Day_lower': this.options.column_width * 2 / 2,
'Half Day_upper': 0,
Day_lower: this.options.column_width / 2,
Day_upper: this.options.column_width * 30 / 2,
Week_lower: 0,
Week_upper: this.options.column_width * 4 / 2,
Month_lower: this.options.column_width / 2,
Month_upper: this.options.column_width * 12 / 2,
Year_lower: this.options.column_width / 2,
Year_upper: this.options.column_width * 30 / 2
};
return {
upper_text: date_text[`${this.options.view_mode}_upper`],
lower_text: date_text[`${this.options.view_mode}_lower`],
upper_x: base_pos.x + x_pos[`${this.options.view_mode}_upper`],
upper_y: base_pos.upper_y,
lower_x: base_pos.x + x_pos[`${this.options.view_mode}_lower`],
lower_y: base_pos.lower_y
};
}
make_bars() {
this.bars = this.tasks.map(task => {
const bar = new Bar(this, task);
this.layers.bar.appendChild(bar.group);
return bar;
});
}
make_arrows() {
this.arrows = [];
for (let task of this.tasks) {
let arrows = [];
arrows = task.dependencies
.map(task_id => {
const dependency = this.get_task(task_id);
if (!dependency) return;
const arrow = new Arrow(
this,
this.bars[dependency._index], // from_task
this.bars[task._index] // to_task
);
this.layers.arrow.appendChild(arrow.element);
return arrow;
})
.filter(Boolean); // filter falsy values
this.arrows = this.arrows.concat(arrows);
}
}
map_arrows_on_bars() {
for (let bar of this.bars) {
bar.arrows = this.arrows.filter(arrow => {
return (
arrow.from_task.task.id === bar.task.id ||
arrow.to_task.task.id === bar.task.id
);
});
}
}
set_width() {
const cur_width = this.$svg.getBoundingClientRect().width;
const actual_width = this.$svg
.querySelector('.grid .grid-row')
.getAttribute('width');
if (cur_width < actual_width) {
this.$svg.setAttribute('width', actual_width);
}
}
set_scroll_position() {
const parent_element = this.$svg.parentElement;
if (!parent_element) return;
const hours_before_first_task = date_utils.diff(
this.get_oldest_starting_date(),
this.gantt_start,
'hour'
);
const scroll_pos =
hours_before_first_task /
this.options.step *
this.options.column_width -
this.options.column_width;
parent_element.scrollLeft = scroll_pos;
}
bind_grid_click() {
$.on(
this.$svg,
this.options.popup_trigger,
'.grid-row, .grid-header',
() => {
this.unselect_all();
this.hide_popup();
}
);
}
bind_bar_events() {
let is_dragging = false;
let x_on_start = 0;
let y_on_start = 0;
let is_resizing_left = false;
let is_resizing_right = false;
let parent_bar_id = null;
let bars = []; // instanceof Bar
this.bar_being_dragged = null;
function action_in_progress() {
return is_dragging || is_resizing_left || is_resizing_right;
}
$.on(this.$svg, 'mousedown', '.bar-wrapper, .handle', (e, element) => {
const bar_wrapper = $.closest('.bar-wrapper', element);
if (element.classList.contains('left')) {
is_resizing_left = true;
} else if (element.classList.contains('right')) {
is_resizing_right = true;
} else if (element.classList.contains('bar-wrapper')) {
is_dragging = true;
}
bar_wrapper.classList.add('active');
x_on_start = e.offsetX;
y_on_start = e.offsetY;
parent_bar_id = bar_wrapper.getAttribute('data-id');
const ids = [
parent_bar_id,
...this.get_all_dependent_tasks(parent_bar_id)
];
bars = ids.map(id => this.get_bar(id));
this.bar_being_dragged = parent_bar_id;
bars.forEach(bar => {
const $bar = bar.$bar;
$bar.ox = $bar.getX();
$bar.oy = $bar.getY();
$bar.owidth = $bar.getWidth();
$bar.finaldx = 0;
});
});
$.on(this.$svg, 'mousemove', e => {
if (!action_in_progress()) return;
const dx = e.offsetX - x_on_start;
const dy = e.offsetY - y_on_start;
bars.forEach(bar => {
const $bar = bar.$bar;
$bar.finaldx = this.get_snap_position(dx);
if (is_resizing_left) {
if (parent_bar_id === bar.task.id) {
bar.update_bar_position({
x: $bar.ox + $bar.finaldx,
width: $bar.owidth - $bar.finaldx
});
} else {
bar.update_bar_position({
x: $bar.ox + $bar.finaldx
});
}
} else if (is_resizing_right) {
if (parent_bar_id === bar.task.id) {
bar.update_bar_position({
width: $bar.owidth + $bar.finaldx
});
}
} else if (is_dragging) {
bar.update_bar_position({ x: $bar.ox + $bar.finaldx });
}
});
});
document.addEventListener('mouseup', e => {
if (is_dragging || is_resizing_left || is_resizing_right) {
bars.forEach(bar => bar.group.classList.remove('active'));
}
is_dragging = false;
is_resizing_left = false;
is_resizing_right = false;
});
$.on(this.$svg, 'mouseup', e => {
this.bar_being_dragged = null;
bars.forEach(bar => {
const $bar = bar.$bar;
if (!$bar.finaldx) return;
bar.date_changed();
bar.set_action_completed();
});
});
this.bind_bar_progress();
}
bind_bar_progress() {
let x_on_start = 0;
let y_on_start = 0;
let is_resizing = null;
let bar = null;
let $bar_progress = null;
let $bar = null;
$.on(this.$svg, 'mousedown', '.handle.progress', (e, handle) => {
is_resizing = true;
x_on_start = e.offsetX;
y_on_start = e.offsetY;
const $bar_wrapper = $.closest('.bar-wrapper', handle);
const id = $bar_wrapper.getAttribute('data-id');
bar = this.get_bar(id);
$bar_progress = bar.$bar_progress;
$bar = bar.$bar;
$bar_progress.finaldx = 0;
$bar_progress.owidth = $bar_progress.getWidth();
$bar_progress.min_dx = -$bar_progress.getWidth();
$bar_progress.max_dx = $bar.getWidth() - $bar_progress.getWidth();
});
$.on(this.$svg, 'mousemove', e => {
if (!is_resizing) return;
let dx = e.offsetX - x_on_start;
let dy = e.offsetY - y_on_start;
if (dx > $bar_progress.max_dx) {
dx = $bar_progress.max_dx;
}
if (dx < $bar_progress.min_dx) {
dx = $bar_progress.min_dx;
}
const $handle = bar.$handle_progress;
$.attr($bar_progress, 'width', $bar_progress.owidth + dx);
$.attr($handle, 'points', bar.get_progress_polygon_points());
$bar_progress.finaldx = dx;
});
$.on(this.$svg, 'mouseup', () => {
is_resizing = false;
if (!($bar_progress && $bar_progress.finaldx)) return;
bar.progress_changed();
bar.set_action_completed();
});
}
get_all_dependent_tasks(task_id) {
let out = [];
let to_process = [task_id];
while (to_process.length) {
const deps = to_process.reduce((acc, curr) => {
acc = acc.concat(this.dependency_map[curr]);
return acc;
}, []);
out = out.concat(deps);
to_process = deps.filter(d => !to_process.includes(d));
}
return out.filter(Boolean);
}
get_snap_position(dx) {
let odx = dx,
rem,
position;
if (this.view_is(VIEW_MODE.WEEK)) {
rem = dx % (this.options.column_width / 7);
position =
odx -
rem +
(rem < this.options.column_width / 14
? 0
: this.options.column_width / 7);
} else if (this.view_is(VIEW_MODE.MONTH)) {
rem = dx % (this.options.column_width / 30);
position =
odx -
rem +
(rem < this.options.column_width / 60
? 0
: this.options.column_width / 30);
} else {
rem = dx % this.options.column_width;
position =
odx -
rem +
(rem < this.options.column_width / 2
? 0
: this.options.column_width);
}
return position;
}
unselect_all() {
[...this.$svg.querySelectorAll('.bar-wrapper')].forEach(el => {
el.classList.remove('active');
});
}
view_is(modes) {
if (typeof modes === 'string') {
return this.options.view_mode === modes;
}
if (Array.isArray(modes)) {
return modes.some(mode => this.options.view_mode === mode);
}
return false;
}
get_task(id) {
return this.tasks.find(task => {
return task.id === id;
});
}
get_bar(id) {
return this.bars.find(bar => {
return bar.task.id === id;
});
}
show_popup(options) {
if (!this.popup) {
this.popup = new Popup(
this.popup_wrapper,
this.options.custom_popup_html
);
}
this.popup.show(options);
}
hide_popup() {
this.popup && this.popup.hide();
}
trigger_event(event, args) {
if (this.options['on_' + event]) {
this.options['on_' + event].apply(null, args);
}
}
/**
* Gets the oldest starting date from the list of tasks
*
* @returns Date
* @memberof Gantt
*/
get_oldest_starting_date() {
return this.tasks
.map(task => task._start)
.reduce(
(prev_date, cur_date) =>
cur_date <= prev_date ? cur_date : prev_date
);
}
/**
* Clear all elements from the parent svg element
*
* @memberof Gantt
*/
clear() {
this.$svg.innerHTML = '';
}
}
Gantt.VIEW_MODE = VIEW_MODE;
function generate_id(task) {
return (
task.name +
'_' +
Math.random()
.toString(36)
.slice(2, 12)
);
}
return Gantt;
}());