diff --git a/app.text.js b/app.text.js index 350a534..131baf9 100644 --- a/app.text.js +++ b/app.text.js @@ -36,11 +36,17 @@ export const BUTTONS = { MORE: "Ещё" //Догрузка данных }; -//Текст элементов ввода -export const INPUTS = { +//Метки атрибутов, сопроводительные надписи +export const CAPTIONS = { VALUE: "Значение", VALUE_FROM: "С", - VALUE_TO: "По" + VALUE_TO: "По", + NUMB: "Номер", + NAME: "Наименование", + START: "Начало", + END: "Окончание", + PROGRESS: "Прогресс", + LEGEND: "Легенда" }; //Типовые сообщения об ошибках diff --git a/app/components/p8p_gantt.js b/app/components/p8p_gantt.js new file mode 100644 index 0000000..c7bc3f3 --- /dev/null +++ b/app/components/p8p_gantt.js @@ -0,0 +1,462 @@ +/* + Парус 8 - Панели мониторинга + Компонент: Диаграмма Ганта +*/ + +//--------------------- +//Подключение библиотек +//--------------------- + +import React, { useEffect, useState, useCallback } from "react"; //Классы React +import PropTypes from "prop-types"; //Контроль свойств компонента +import { + Box, + IconButton, + Icon, + Typography, + Dialog, + DialogActions, + DialogContent, + TextField, + Button, + List, + ListItem, + ListItemText, + Divider, + Slider, + Link +} from "@mui/material"; //Интерфейсные компоненты +import { P8PAppInlineError } from "./p8p_app_message"; //Встраиваемое сообщение об ошибке + +//--------- +//Константы +//--------- + +//Уровни масштаба +const P8P_GANTT_ZOOM = [0, 1, 2, 3, 4]; + +//Уровни масштаба (строковые наименования в терминах библиотеки) +const P8P_GANTT_ZOOM_VIEW_MODES = { + 0: "Quarter Day", + 1: "Half Day", + 2: "Day", + 3: "Week", + 4: "Month" +}; + +//Структура задачи +const P8P_GANTT_TASK_SHAPE = PropTypes.shape({ + id: PropTypes.string.isRequired, + rn: PropTypes.number.isRequired, + numb: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + fullName: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + progress: PropTypes.number, + dependencies: PropTypes.array, + readOnly: PropTypes.bool, + readOnlyDates: PropTypes.bool, + readOnlyProgress: PropTypes.bool, + bgColor: PropTypes.string, + textColor: PropTypes.string +}); + +//Структура динамического атрибута задачи +const P8P_GANTT_TASK_ATTRIBUTE_SHAPE = PropTypes.shape({ + name: PropTypes.string.isRequired, + caption: PropTypes.string.isRequired +}); + +//Структура описания цвета задачи +const P8P_GANTT_TASK_COLOR_SHAPE = PropTypes.shape({ + bgColor: PropTypes.string, + textColor: PropTypes.string, + desc: PropTypes.string.isRequired +}); + +//Стили +const STYLES = { + TASK_EDITOR_LIST: { width: "100%", minWidth: 300, maxWidth: 700, bgcolor: "background.paper" } +}; + +//-------------------------------- +//Вспомогательные классы и функции +//-------------------------------- + +//Проверка существования значения +const hasValue = value => typeof value !== "undefined" && value !== null && value !== ""; + +//Редактор задачи +const P8PGanttTaskEditor = ({ + task, + taskAttributes, + taskColors, + onOk, + onCancel, + taskAttributeRenderer, + numbCaption, + nameCaption, + startCaption, + endCaption, + progressCaption, + legendCaption, + okBtnCaption, + cancelBtnCaption +}) => { + //Собственное состояние + const [state, setState] = useState({ + start: task.start, + end: task.end, + progress: task.progress + }); + + //При сохранении + const handleOk = () => (onOk && state.start && state.end ? onOk({ task, start: state.start, end: state.end, progress: state.progress }) : null); + + //При отмене + const handleCancel = () => (onCancel ? onCancel() : null); + + //При изменении сроков + const handlePeriodChanged = e => { + setState(prev => ({ ...prev, [e.target.name]: e.target.value })); + }; + + //При изменении прогресса + const handleProgressChanged = (e, newValue) => { + console.log(newValue); + setState(prev => ({ ...prev, progress: newValue })); + }; + + //Описание легенды для задачи + let legend = null; + if (Array.isArray(taskColors)) { + const colorDesc = taskColors.find(color => task.bgColor === color.bgColor && task.textColor === color.textColor); + if (colorDesc) + legend = ( + + ); + } + + //Генерация содержимого + return ( + + + + + + + + + + + + + + } + /> + + + + + } + /> + + + {hasValue(task.progress) ? ( + <> + + + } + /> + + + + ) : null} + {legend ? ( + <> + {legend} + + + ) : null} + {Array.isArray(taskAttributes) && taskAttributes.length > 0 + ? taskAttributes + .filter(attr => hasValue(task[attr.name])) + .map((attr, i) => { + const defaultView = task[attr.name]; + const customView = taskAttributeRenderer ? taskAttributeRenderer({ task, attribute: attr }) : null; + return ( + + + + + {i < taskAttributes.length - 1 ? : null} + + ); + }) + : null} + + + + + + + + ); +}; + +//Контроль свойств - Редактор задачи +P8PGanttTaskEditor.propTypes = { + task: P8P_GANTT_TASK_SHAPE, + taskAttributes: PropTypes.arrayOf(P8P_GANTT_TASK_ATTRIBUTE_SHAPE), + taskColors: PropTypes.arrayOf(P8P_GANTT_TASK_COLOR_SHAPE), + onOk: PropTypes.func, + onCancel: PropTypes.func, + taskAttributeRenderer: PropTypes.func, + numbCaption: PropTypes.string.isRequired, + nameCaption: PropTypes.string.isRequired, + startCaption: PropTypes.string.isRequired, + endCaption: PropTypes.string.isRequired, + progressCaption: PropTypes.string.isRequired, + legendCaption: PropTypes.string.isRequired, + okBtnCaption: PropTypes.string.isRequired, + cancelBtnCaption: PropTypes.string.isRequired +}; + +//----------- +//Тело модуля +//----------- + +//Диаграмма Ганта +const P8PGantt = ({ + height, + title, + titleStyle, + onTitleClick, + zoomBar, + readOnly, + readOnlyDates, + readOnlyProgress, + zoom, + tasks, + taskAttributes, + taskColors, + onTaskDatesChange, + onTaskProgressChange, + taskAttributeRenderer, + noDataFoundText, + numbTaskEditorCaption, + nameTaskEditorCaption, + startTaskEditorCaption, + endTaskEditorCaption, + progressTaskEditorCaption, + legendTaskEditorCaption, + okTaskEditorBtnCaption, + cancelTaskEditorBtnCaption +}) => { + //Собственное состояние + const [state, setState] = useState({ + noData: true, + gantt: null, + zoom: P8P_GANTT_ZOOM.includes(zoom) ? zoom : 3, + editTask: null + }); + + //Отображение диаграммы + const showGantt = useCallback(() => { + if (!state.gantt) { + // eslint-disable-next-line no-undef + const gantt = new Gantt("#__gantt__", tasks, { + view_mode: P8P_GANTT_ZOOM_VIEW_MODES[state.zoom], + date_format: "YYYY-MM-DD", + language: "ru", + readOnly, + readOnlyDates, + readOnlyProgress, + on_date_change: (task, start, end, isMain) => (onTaskDatesChange ? onTaskDatesChange({ task, start, end, isMain }) : null), + on_progress_change: (task, progress) => (onTaskProgressChange ? onTaskProgressChange({ task, progress }) : null), + on_click: openTaskEditor + }); + setState(pv => ({ ...pv, gantt, noData: false })); + } else { + state.gantt.refresh(tasks); + setState(pv => ({ ...pv, noData: false })); + } + }, [state.gantt, state.zoom, readOnly, readOnlyDates, readOnlyProgress, tasks, onTaskDatesChange, onTaskProgressChange]); + + //Обновление масштаба диаграммы + const handleZoomChange = direction => + setState(pv => ({ + ...pv, + zoom: pv.zoom + direction < 0 ? 0 : pv.zoom + direction >= P8P_GANTT_ZOOM.length ? P8P_GANTT_ZOOM.length - 1 : pv.zoom + direction + })); + + //Открытие редактора задачи + const openTaskEditor = task => setState(pv => ({ ...pv, editTask: { ...task } })); + + //При сохранении задачи в редакторе + const handleTaskEditorSave = ({ task, start, end, progress }) => { + setState(pv => ({ ...pv, editTask: null })); + if (onTaskDatesChange && (task.start != start || task.end != end)) onTaskDatesChange({ task, start, end, isMain: true }); + if (onTaskProgressChange && task.progress != progress) onTaskProgressChange({ task, progress }); + }; + + //При закрытии редактора задачи без сохранения + const handleTaskEditorCancel = () => setState(pv => ({ ...pv, editTask: null })); + + //При изменении масштаба + useEffect(() => { + if (state.gantt) state.gantt.change_view_mode(P8P_GANTT_ZOOM_VIEW_MODES[state.zoom]); + }, [state.gantt, state.zoom]); + + //При изменении списка задач + useEffect(() => { + if (Array.isArray(tasks) && tasks.length > 0) showGantt(); + else setState(pv => ({ ...pv, noData: true })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tasks]); + + //Генерация содержимого + return ( +
+ {state.gantt && state.noData ? : null} + {state.gantt && !state.noData && title ? ( + + {onTitleClick ? ( + onTitleClick()}> + {title} + + ) : ( + title + )} + + ) : null} + {state.gantt && !state.noData && zoomBar ? ( + + handleZoomChange(-1)} disabled={state.zoom == 0}> + zoom_in + + handleZoomChange(1)} disabled={state.zoom == P8P_GANTT_ZOOM.length - 1}> + zoom_out + + + ) : null} + {state.editTask ? ( + + ) : null} +
+ +
+
+ ); +}; + +//Контроль свойств - Диаграмма Ганта +P8PGantt.propTypes = { + height: PropTypes.string.isRequired, + title: PropTypes.string, + titleStyle: PropTypes.object, + onTitleClick: PropTypes.func, + zoomBar: PropTypes.bool, + readOnly: PropTypes.bool, + readOnlyDates: PropTypes.bool, + readOnlyProgress: PropTypes.bool, + zoom: PropTypes.number, + tasks: PropTypes.arrayOf(P8P_GANTT_TASK_SHAPE).isRequired, + taskAttributes: PropTypes.arrayOf(P8P_GANTT_TASK_ATTRIBUTE_SHAPE), + taskColors: PropTypes.arrayOf(P8P_GANTT_TASK_COLOR_SHAPE), + onTaskDatesChange: PropTypes.func, + onTaskProgressChange: PropTypes.func, + taskAttributeRenderer: PropTypes.func, + noDataFoundText: PropTypes.string.isRequired, + numbTaskEditorCaption: PropTypes.string.isRequired, + nameTaskEditorCaption: PropTypes.string.isRequired, + startTaskEditorCaption: PropTypes.string.isRequired, + endTaskEditorCaption: PropTypes.string.isRequired, + progressTaskEditorCaption: PropTypes.string.isRequired, + legendTaskEditorCaption: PropTypes.string.isRequired, + okTaskEditorBtnCaption: PropTypes.string.isRequired, + cancelTaskEditorBtnCaption: PropTypes.string.isRequired +}; + +//---------------- +//Интерфейс модуля +//---------------- + +export { P8P_GANTT_TASK_SHAPE, P8P_GANTT_TASK_ATTRIBUTE_SHAPE, P8P_GANTT_TASK_COLOR_SHAPE, P8PGantt }; diff --git a/app/config_wrapper.js b/app/config_wrapper.js index 032df34..facbf09 100644 --- a/app/config_wrapper.js +++ b/app/config_wrapper.js @@ -9,11 +9,12 @@ import React from "react"; //Классы React import { deepCopyObject } from "./core/utils"; //Вспомогательные процедуры и функции -import { TITLES, BUTTONS, TEXTS, INPUTS } from "../app.text"; //Текстовые ресурсы и константы +import { TITLES, BUTTONS, TEXTS, CAPTIONS } from "../app.text"; //Текстовые ресурсы и константы import { P8PPanelsMenuGrid, P8P_PANELS_MENU_PANEL_SHAPE } from "./components/p8p_data_grid"; //Меню панелей import { P8PAppWorkspace } from "./components/p8p_app_workspace"; //Рабочее пространство import { P8PTable, P8P_TABLE_DATA_TYPE, P8P_TABLE_SIZE, P8P_TABLE_FILTER_SHAPE } from "./components/p8p_data_grid"; //Таблица данных import { P8PDataGrid, P8P_DATA_GRID_DATA_TYPE, P8P_DATA_GRID_SIZE, P8P_DATA_GRID_FILTER_SHAPE } from "./components/p8p_data_grid"; //Таблица данных +import { P8PGantt, P8P_GANTT_TASK_SHAPE, P8P_GANTT_TASK_ATTRIBUTE_SHAPE, P8P_GANTT_TASK_COLOR_SHAPE } from "./components/p8p_gantt"; //Диаграмма Ганта //--------- //Константы @@ -36,9 +37,9 @@ const P8P_TABLE_CONFIG_PROPS = { orderAscMenuItemCaption: BUTTONS.ORDER_ASC, orderDescMenuItemCaption: BUTTONS.ORDER_DESC, filterMenuItemCaption: BUTTONS.FILTER, - valueFilterCaption: INPUTS.VALUE, - valueFromFilterCaption: INPUTS.VALUE_FROM, - valueToFilterCaption: INPUTS.VALUE_TO, + valueFilterCaption: CAPTIONS.VALUE, + valueFromFilterCaption: CAPTIONS.VALUE_FROM, + valueToFilterCaption: CAPTIONS.VALUE_TO, okFilterBtnCaption: BUTTONS.OK, clearFilterBtnCaption: BUTTONS.CLEAR, cancelFilterBtnCaption: BUTTONS.CANCEL, @@ -51,9 +52,9 @@ const P8P_DATA_GRID_CONFIG_PROPS = { orderAscMenuItemCaption: BUTTONS.ORDER_ASC, orderDescMenuItemCaption: BUTTONS.ORDER_DESC, filterMenuItemCaption: BUTTONS.FILTER, - valueFilterCaption: INPUTS.VALUE, - valueFromFilterCaption: INPUTS.VALUE_FROM, - valueToFilterCaption: INPUTS.VALUE_TO, + valueFilterCaption: CAPTIONS.VALUE, + valueFromFilterCaption: CAPTIONS.VALUE_FROM, + valueToFilterCaption: CAPTIONS.VALUE_TO, okFilterBtnCaption: BUTTONS.OK, clearFilterBtnCaption: BUTTONS.CLEAR, cancelFilterBtnCaption: BUTTONS.CANCEL, @@ -62,6 +63,19 @@ const P8P_DATA_GRID_CONFIG_PROPS = { objectsCopier: deepCopyObject }; +//Конфигурируемые свойства "Диаграммы Ганта" (P8PGantt) +const P8P_GANTT_CONFIG_PROPS = { + noDataFoundText: TEXTS.NO_DATA_FOUND, + numbTaskEditorCaption: CAPTIONS.NUMB, + nameTaskEditorCaption: CAPTIONS.NAME, + startTaskEditorCaption: CAPTIONS.START, + endTaskEditorCaption: CAPTIONS.END, + progressTaskEditorCaption: CAPTIONS.PROGRESS, + legendTaskEditorCaption: CAPTIONS.LEGEND, + okTaskEditorBtnCaption: BUTTONS.OK, + cancelTaskEditorBtnCaption: BUTTONS.CANCEL +}; + //----------------------- //Вспомогательные функции //----------------------- @@ -75,6 +89,7 @@ const addConfigChildProps = children => if (child.type.name === "P8PPanelsMenuGrid") configProps = P8P_PANELS_MENU_GRID_CONFIG_PROPS; if (child.type.name === "P8PTable") configProps = P8P_TABLE_CONFIG_PROPS; if (child.type.name === "P8PDataGrid") configProps = P8P_DATA_GRID_CONFIG_PROPS; + if (child.type.name === "P8PGantt") configProps = P8P_GANTT_CONFIG_PROPS; return React.createElement(child.type, { ...configProps, ...restProps }, addConfigChildProps(children)); }); @@ -89,11 +104,14 @@ const P8PPanelsMenuGridConfigWrapped = (props = {}) => ; //Обёртка для компонента "Таблица" (P8PTable) -const P8PTableConfigWrapped = (props = {}) => ; +const P8PTableConfigWrapped = (props = {}) => ; //Обёртка для компонента "Таблица данных" (P8PDataGrid) const P8PDataGridConfigWrapped = (props = {}) => ; +//Обёртка для компонента "Диаграмма Ганта" (P8PGantt) +const P8PGanttConfigWrapped = (props = {}) => ; + //Универсальный элемент-обёртка в параметры конфигурации const ConfigWrapper = ({ children }) => addConfigChildProps(children); @@ -113,9 +131,14 @@ export { P8P_DATA_GRID_DATA_TYPE, P8P_DATA_GRID_SIZE, P8P_DATA_GRID_FILTER_SHAPE, + P8P_GANTT_CONFIG_PROPS, + P8P_GANTT_TASK_SHAPE, + P8P_GANTT_TASK_ATTRIBUTE_SHAPE, + P8P_GANTT_TASK_COLOR_SHAPE, P8PPanelsMenuGridConfigWrapped, P8PAppWorkspaceConfigWrapped, P8PTableConfigWrapped, P8PDataGridConfigWrapped, + P8PGanttConfigWrapped, ConfigWrapper }; diff --git a/index.html b/index.html index 24d5dbe..236ddf3 100644 --- a/index.html +++ b/index.html @@ -3,13 +3,18 @@ + Парус 8 - Панели мониторинга + + - Парус 8 - Панели мониторинга + + +
diff --git a/libs/frappe-gantt/frappe-gantt.css b/libs/frappe-gantt/frappe-gantt.css new file mode 100644 index 0000000..e51e271 --- /dev/null +++ b/libs/frappe-gantt/frappe-gantt.css @@ -0,0 +1,134 @@ +.gantt .grid-background { + fill: none; +} +.gantt .grid-header { + fill: #ffffff; + stroke: #e0e0e0; + stroke-width: 1.4; +} +.gantt .grid-row { + fill: #ffffff; +} +.gantt .grid-row:nth-child(even) { + fill: #f5f5f5; +} +.gantt .row-line { + stroke: #ebeff2; +} +.gantt .tick { + stroke: #e0e0e0; + stroke-width: 0.2; +} +.gantt .tick.thick { + stroke-width: 0.4; +} +.gantt .today-highlight { + fill: #fcf8e3; + opacity: 0.5; +} +.gantt .arrow { + fill: none; + stroke: #666; + stroke-width: 1.4; +} +.gantt .bar { + fill: #b8c2cc; + stroke: #8D99A6; + stroke-width: 0; + transition: stroke-width 0.3s ease; + user-select: none; +} +.gantt .bar-progress { + fill: #a3a3ff; +} +.gantt .bar-invalid { + fill: transparent; + stroke: #8D99A6; + stroke-width: 1; + stroke-dasharray: 5; +} +.gantt .bar-invalid ~ .bar-label { + fill: #555; +} +.gantt .bar-label { + fill: #fff; + dominant-baseline: central; + text-anchor: middle; + font-size: 12px; + font-weight: lighter; +} +.gantt .bar-label.big { + fill: #555; + text-anchor: start; +} +.gantt .handle { + fill: #ddd; + cursor: ew-resize; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease; +} +.gantt .bar-wrapper { + cursor: pointer; + outline: none; +} +.gantt .bar-wrapper:hover .bar { + fill: #a9b5c1; +} +.gantt .bar-wrapper:hover .bar-progress { + fill: #8a8aff; +} +.gantt .bar-wrapper:hover .handle { + visibility: visible; + opacity: 1; +} +.gantt .bar-wrapper.active .bar { + fill: #a9b5c1; +} +.gantt .bar-wrapper.active .bar-progress { + fill: #8a8aff; +} +.gantt .lower-text, .gantt .upper-text { + font-size: 12px; + text-anchor: middle; +} +.gantt .upper-text { + fill: #555; +} +.gantt .lower-text { + fill: #333; +} +.gantt .hide { + display: none; +} + +.gantt-container { + position: relative; + overflow: auto; + font-size: 12px; + height: 100%; +} +.gantt-container .popup-wrapper { + position: absolute; + top: 0; + left: 0; + background: rgba(0, 0, 0, 0.8); + padding: 0; + color: #959da5; + border-radius: 3px; +} +.gantt-container .popup-wrapper .title { + border-bottom: 3px solid #a3a3ff; + padding: 10px; +} +.gantt-container .popup-wrapper .subtitle { + padding: 10px; + color: #dfe2e5; +} +.gantt-container .popup-wrapper .pointer { + position: absolute; + height: 5px; + margin: 0 0 0 -5px; + border: 5px solid transparent; + border-top-color: rgba(0, 0, 0, 0.8); +} \ No newline at end of file diff --git a/libs/frappe-gantt/frappe-gantt.js b/libs/frappe-gantt/frappe-gantt.js new file mode 100644 index 0000000..0ca18a0 --- /dev/null +++ b/libs/frappe-gantt/frappe-gantt.js @@ -0,0 +1,1919 @@ +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'; + + 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 dateTimeFormat = new Intl.DateTimeFormat(lang, { + month: 'long' + }); + const month_name = dateTimeFormat.format(date); + const month_name_capitalized = + month_name.charAt(0).toUpperCase() + month_name.slice(1); + + 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_name_capitalized, + MMM: month_name_capitalized, + }; + + 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, + }); + //ЦИТК + if (this.task.bgColor) this.$bar.style.fill = this.task.bgColor; + //ЦИТК + + 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() { + let 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, + }); + //ЦИТК + if (this.task.textColor) label.style.fill = this.task.textColor; + //ЦИТК + + // labels get BBox in the next tick + requestAnimationFrame(() => this.update_label_position()); + } + + draw_resize_handles() { + //ЦИТК + if (this.invalid || this.task.readOnly) return; + //ЦИТК + + const bar = this.$bar; + const handle_width = 8; + + //ЦИТК + if (!(this.task.readOnlyDates === true)) { + //ЦИТК + 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.task.readOnlyProgress) { + //ЦИТК + 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(isMain) { + //ЦИТК + 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'), + //ЦИТК + isMain + //ЦИТК + ]); + } + + progress_changed() { + const new_progress = this.compute_progress(); + //ЦИТК + if(this.task.progress != new_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() { + //ЦИТК + if (this.invalid || !this.task.progress) return; + //ЦИТК + 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() { + if (this.invalid) return; + 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 = ` +
+
+
+ `; + + 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 += '
'; + 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; + this.parent.style.left = 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: 'ru', + readOnly: false, + readOnlyDates: false, + readOnlyProgress: false + //ЦИТК + }; + 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); + } + + //ЦИТК + task.readOnly = [true, false].includes(task.readOnly) ? task.readOnly : this.options.readOnly; + task.readOnlyDates = [true, false].includes(task.readOnlyDates) ? task.readOnlyDates : this.options.readOnlyDates; + task.readOnlyProgress = [true, false].includes(task.readOnlyProgress) ? task.readOnlyProgress : this.options.readOnlyProgress; + //ЦИТК + + 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() % 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); + //ЦИТК + parent_bar_id = bar_wrapper.getAttribute('data-id'); + if (this.get_bar(parent_bar_id).task.readOnly || this.get_bar(parent_bar_id).task.readOnlyDates) return; + //ЦИТК + + 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; + + 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; + e.offsetY - y_on_start; + + bars.forEach((bar) => { + const $bar = bar.$bar; + $bar.finaldx = this.get_snap_position(dx); + this.hide_popup(); + 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) => { + //ЦИТК + const fireId = this.bar_being_dragged; + //ЦИТК + this.bar_being_dragged = null; + bars.forEach((bar) => { + const $bar = bar.$bar; + if (!$bar.finaldx) return; + //ЦИТК + bar.date_changed((bar.task.id == fireId)); + //ЦИТК + 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; + 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; + +})(); +//# sourceMappingURL=frappe-gantt.js.map