[v0.1.0] Major Refactor

- Remove moment and Snap dependencies
- Use Rollup as build tool
- Use Prettier for linting/styling
- Use Jest for testing
- Use yarn
This commit is contained in:
Faris Ansari 2018-02-11 16:48:44 +05:30
parent c8bfc1d679
commit e55107ee82
22 changed files with 7585 additions and 3290 deletions

View File

@ -1,4 +1,3 @@
{
"presets": ["es2015"],
"plugins": ["babel-plugin-add-module-exports"]
"presets": ["env"]
}

185
.eslintrc Executable file → Normal file
View File

@ -1,182 +1,7 @@
{
"ecmaFeatures": {
"globalReturn": true,
"jsx": true,
"modules": true
},
"env": {
"browser": true,
"es6": true,
"node": true
},
"globals": {
"document": false,
"escape": false,
"navigator": false,
"unescape": false,
"window": false,
"describe": true,
"before": true,
"it": true,
"expect": true,
"sinon": true
},
"parser": "babel-eslint",
"plugins": [
],
"rules": {
"block-scoped-var": 2,
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"comma-dangle": [2, "never"],
"comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"],
"complexity": 0,
"consistent-return": 2,
"consistent-this": 0,
"curly": [2, "multi-line"],
"default-case": 0,
"dot-location": [2, "property"],
"dot-notation": 0,
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"func-names": 0,
"func-style": 0,
"generator-star-spacing": [2, "both"],
"guard-for-in": 0,
"handle-callback-err": [2, "^(err|error|anySpecificError)$" ],
"indent": [2, "tab", { "SwitchCase": 1 }],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"linebreak-style": 0,
"max-depth": 0,
"max-len": [2, 120, 4],
"max-nested-callbacks": 0,
"max-params": 0,
"max-statements": 0,
"new-cap": [2, { "newIsCap": true, "capIsNew": false }],
"new-parens": 2,
"no-alert": 0,
"no-array-constructor": 2,
"no-bitwise": 0,
"no-caller": 2,
"no-catch-shadow": 0,
"no-cond-assign": 2,
"no-console": 0,
"no-constant-condition": 0,
"no-continue": 0,
"no-control-regex": 2,
"no-debugger": 2,
"no-delete-var": 2,
"no-div-regex": 0,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-else-return": 2,
"no-empty": 0,
"no-empty-character-class": 2,
"no-empty-label": 2,
"no-eq-null": 0,
"no-eval": 2,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": 0,
"no-extra-semi": 0,
"no-extra-strict": 0,
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inline-comments": 0,
"no-inner-declarations": [2, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 0,
"no-lonely-if": 0,
"no-loop-func": 0,
"no-mixed-requires": 0,
"no-mixed-spaces-and-tabs": [2, false],
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 1 }],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-nested-ternary": 0,
"no-new": 2,
"no-new-func": 2,
"no-new-object": 2,
"no-new-require": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-path-concat": 0,
"no-plusplus": 0,
"no-process-env": 0,
"no-process-exit": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-reserved-keys": 0,
"no-restricted-modules": 0,
"no-return-assign": 2,
"no-script-url": 0,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 0,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-sync": 0,
"no-ternary": 0,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-undefined": 0,
"no-underscore-dangle": 0,
"no-unneeded-ternary": 2,
"no-unreachable": 2,
"no-unused-expressions": 0,
"no-unused-vars": [2, { "vars": "all", "args": "none" }],
"no-var": 0,
"no-void": 0,
"no-warning-comments": 0,
"no-with": 2,
"one-var": 0,
"operator-assignment": 0,
"operator-linebreak": [2, "after"],
"padded-blocks": 0,
"quote-props": 0,
"quotes": [2, "single", "avoid-escape"],
"radix": 2,
"semi": [2, "always"],
"semi-spacing": 0,
"sort-vars": 0,
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, {"anonymous": "always", "named": "never"}],
"space-in-brackets": 0,
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-return-throw-case": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": [2, "always"],
"strict": 0,
"use-isnan": 2,
"valid-jsdoc": 0,
"valid-typeof": 2,
"vars-on-top": 2,
"wrap-iife": [2, "any"],
"wrap-regex": 0,
"yoda": [2, "never"]
}
"extends": ["plugin:prettier/recommended"],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
}
}

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"tabWidth": 4,
"singleQuote": true
}

113
dist/frappe-gantt.css vendored Normal file
View File

@ -0,0 +1,113 @@
.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 .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 .3s ease; }
.gantt .bar-wrapper {
cursor: pointer; }
.gantt .bar-wrapper:hover .bar {
stroke-width: 2; }
.gantt .bar-wrapper:hover .handle {
visibility: visible;
opacity: 1; }
.gantt .bar-wrapper.active .bar {
stroke-width: 2; }
.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;
font-size: 12px; }
.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); }

3493
dist/frappe-gantt.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -20,16 +20,13 @@
fill: tomato;
}
</style>
<script src="node_modules/moment/min/moment.min.js"></script>
<script src="node_modules/snapsvg/dist/snap.svg-min.js"></script>
<link rel="stylesheet" href="dist/frappe-gantt.css" />
<script src="dist/frappe-gantt.js"></script>
</head>
<body>
<div class="container">
<h2>Interactive Gantt Chart entirely made in SVG!</h2>
<div class="gantt-container">
<svg id="gantt" width="400" height="600"></svg>
</div>
<div class="gantt-target"></div>
</div>
<script>
var names = [
@ -62,7 +59,7 @@
tasks[5].dependencies = "Task 4"
tasks[5].custom_class = "bar-milestone";
var gantt_chart = Gantt("#gantt", tasks, {
var gantt_chart = new Gantt(".gantt-target", tasks, {
on_click: function (task) {
console.log(task);
},

View File

@ -1,30 +1,14 @@
{
"name": "frappe-gantt",
"version": "0.0.7",
"version": "0.1.0",
"description": "A simple, modern, interactive gantt library for the web",
"main": "dist/frappe-gantt.js",
"main": "src/index.js",
"scripts": {
"build": "webpack --mode=build",
"dev": "webpack --progress --colors --watch --mode=dev",
"test": "mocha --compilers js:babel-core/register --colors -w ./test/*.spec.js"
},
"devDependencies": {
"babel": "6.3.13",
"babel-core": "6.1.18",
"babel-eslint": "5.0.0",
"babel-loader": "6.1.0",
"babel-plugin-add-module-exports": "0.1.2",
"babel-preset-es2015": "6.3.13",
"chai": "3.4.1",
"css-loader": "^0.26.1",
"eslint": "1.7.2",
"eslint-loader": "1.1.0",
"mocha": "2.3.4",
"node-sass": "^4.0.0",
"sass-loader": "^4.1.0",
"style-loader": "^0.13.1",
"webpack": "1.12.9",
"yargs": "3.32.0"
"build": "rollup -c",
"dev": "rollup -c -w",
"test": "jest",
"test:watch": "jest --watch",
"prettier": "prettier es6 --write \"{src/*,tests/*,rollup.config}.js\""
},
"repository": {
"type": "git",
@ -44,8 +28,17 @@
"url": "https://github.com/frappe/gantt/issues"
},
"homepage": "https://github.com/frappe/gantt",
"dependencies": {
"moment": "^2.17.1",
"snapsvg": "^0.4.0"
}
"devDependencies": {
"babel-preset-env": "^1.6.1",
"deepmerge": "^2.0.1",
"eslint": "^4.17.0",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-prettier": "^2.6.0",
"jest": "^22.2.1",
"prettier": "1.10.2",
"rollup": "^0.55.3",
"rollup-plugin-sass": "^0.5.3",
"rollup-plugin-uglify": "^3.0.0"
},
"eslintIgnore": ["dist"]
}

25
rollup.config.js Normal file
View File

@ -0,0 +1,25 @@
import sass from 'rollup-plugin-sass';
import uglify from 'rollup-plugin-uglify';
import merge from 'deepmerge';
const dev = {
input: 'src/index.js',
output: {
name: 'Gantt',
file: 'dist/frappe-gantt.js',
format: 'iife'
},
plugins: [
sass({
output: 'dist/frappe-gantt.css'
})
]
};
const prod = merge(dev, {
output: {
file: 'dist/frappe-gantt.min.js'
},
plugins: [uglify()]
});
export default [dev, prod];

View File

@ -1,105 +1,96 @@
/* global Snap */
/*
Class: Arrow
from_task ---> to_task
import { createSVG } from './svg_utils';
Opts:
gantt (Gantt object)
from_task (Bar object)
to_task (Bar object)
*/
export default class Arrow {
constructor(gantt, from_task, to_task) {
this.gantt = gantt;
this.from_task = from_task;
this.to_task = to_task;
export default function Arrow(gt, from_task, to_task) {
this.calculate_path();
this.draw();
}
const self = {};
calculate_path() {
let start_x =
this.from_task.$bar.getX() + this.from_task.$bar.getWidth() / 2;
function init() {
self.from_task = from_task;
self.to_task = to_task;
prepare();
draw();
}
const condition = () =>
this.to_task.$bar.getX() < start_x + this.gantt.options.padding &&
start_x > this.from_task.$bar.getX() + this.gantt.options.padding;
function prepare() {
while (condition()) {
start_x -= 10;
}
self.start_x = from_task.$bar.getX() + from_task.$bar.getWidth() / 2;
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 condition = () =>
to_task.$bar.getX() < self.start_x + gt.config.padding &&
self.start_x > from_task.$bar.getX() + gt.config.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;
while(condition()) {
self.start_x -= 10;
}
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;
self.start_y = gt.config.header_height + gt.config.bar.height +
(gt.config.padding + gt.config.bar.height) * from_task.task._index +
gt.config.padding;
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`;
self.end_x = to_task.$bar.getX() - gt.config.padding / 2;
self.end_y = gt.config.header_height + gt.config.bar.height / 2 +
(gt.config.padding + gt.config.bar.height) * to_task.task._index +
gt.config.padding;
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;
const from_is_below_to = (from_task.task._index > to_task.task._index);
self.curve = gt.config.arrow.curve;
self.clockwise = from_is_below_to ? 1 : 0;
self.curve_y = from_is_below_to ? -self.curve : self.curve;
self.offset = from_is_below_to ?
self.end_y + gt.config.arrow.curve :
self.end_y - gt.config.arrow.curve;
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`;
}
}
self.path =
Snap.format('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',
{
start_x: self.start_x,
start_y: self.start_y,
end_x: self.end_x,
end_y: self.end_y,
offset: self.offset,
curve: self.curve,
clockwise: self.clockwise,
curve_y: self.curve_y
});
draw() {
this.element = createSVG('path', {
d: this.path,
'data-from': this.from_task.task.id,
'data-to': this.to_task.task.id
});
}
if(to_task.$bar.getX() < from_task.$bar.getX() + gt.config.padding) {
self.path =
Snap.format('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',
{
start_x: self.start_x,
start_y: self.start_y,
end_x: self.end_x,
end_y: self.end_y,
down_1: gt.config.padding / 2 - self.curve,
down_2: to_task.$bar.getY() + to_task.$bar.getHeight() / 2 - self.curve_y,
left: to_task.$bar.getX() - gt.config.padding,
offset: self.offset,
curve: self.curve,
clockwise: self.clockwise,
curve_y: self.curve_y
});
}
}
function draw() {
self.element = gt.canvas.path(self.path)
.attr('data-from', self.from_task.task.id)
.attr('data-to', self.to_task.task.id);
}
function update() { // eslint-disable-line
prepare();
self.element.attr('d', self.path);
}
self.update = update;
init();
return self;
update() {
this.calculate_path();
this.element.setAttribute('d', this.path);
}
}

View File

@ -1,529 +1,406 @@
/* global Snap */
/*
Class: Bar
Opts:
gt: Gantt object
task: task object
*/
export default function Bar(gt, task) {
const self = {};
function init() {
set_defaults();
prepare();
draw();
bind();
}
function set_defaults() {
self.action_completed = false;
self.task = task;
}
function prepare() {
prepare_values();
prepare_plugins();
}
function prepare_values() {
self.invalid = self.task.invalid;
self.height = gt.config.bar.height;
self.x = compute_x();
self.y = compute_y();
self.corner_radius = 3;
self.duration = (self.task._end.diff(self.task._start, 'hours') + 24) / gt.config.step;
self.width = gt.config.column_width * self.duration;
self.progress_width = gt.config.column_width * self.duration * (self.task.progress / 100) || 0;
self.group = gt.canvas.group().addClass('bar-wrapper').addClass(self.task.custom_class || '');
self.bar_group = gt.canvas.group().addClass('bar-group').appendTo(self.group);
self.handle_group = gt.canvas.group().addClass('handle-group').appendTo(self.group);
}
function prepare_plugins() {
Snap.plugin(function (Snap, Element, Paper, global, Fragment) {
Element.prototype.getX = function () {
return +this.attr('x');
};
Element.prototype.getY = function () {
return +this.attr('y');
};
Element.prototype.getWidth = function () {
return +this.attr('width');
};
Element.prototype.getHeight = function () {
return +this.attr('height');
};
Element.prototype.getEndX = function () {
return this.getX() + this.getWidth();
};
});
}
function draw() {
draw_bar();
draw_progress_bar();
draw_label();
draw_resize_handles();
}
function draw_bar() {
self.$bar = gt.canvas.rect(self.x, self.y,
self.width, self.height,
self.corner_radius, self.corner_radius)
.addClass('bar')
.appendTo(self.bar_group);
if (self.invalid) {
self.$bar.addClass('bar-invalid');
}
}
function draw_progress_bar() {
if (self.invalid) return;
self.$bar_progress = gt.canvas.rect(self.x, self.y,
self.progress_width, self.height,
self.corner_radius, self.corner_radius)
.addClass('bar-progress')
.appendTo(self.bar_group);
}
function draw_label() {
gt.canvas.text(self.x + self.width / 2,
self.y + self.height / 2,
self.task.name)
.addClass('bar-label')
.appendTo(self.bar_group);
update_label_position();
}
function draw_resize_handles() {
if (self.invalid) return;
const bar = self.$bar,
handle_width = 8;
gt.canvas.rect(bar.getX() + bar.getWidth() - 9, bar.getY() + 1,
handle_width, self.height - 2, self.corner_radius, self.corner_radius)
.addClass('handle right')
.appendTo(self.handle_group);
gt.canvas.rect(bar.getX() + 1, bar.getY() + 1,
handle_width, self.height - 2, self.corner_radius, self.corner_radius)
.addClass('handle left')
.appendTo(self.handle_group);
if (self.task.progress && self.task.progress < 100) {
gt.canvas.polygon(get_progress_polygon_points())
.addClass('handle progress')
.appendTo(self.handle_group);
}
}
function get_progress_polygon_points() {
const bar_progress = self.$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
];
}
function bind() {
if (self.invalid) return;
setup_click_event();
show_details();
bind_resize();
bind_drag();
bind_resize_progress();
}
function show_details() {
const popover_group = gt.element_groups.details;
self.details_box = popover_group
.select(`.details-wrapper[data-task='${self.task.id}']`);
if (!self.details_box) {
self.details_box = gt.canvas.group()
.addClass('details-wrapper hide')
.attr('data-task', self.task.id)
.appendTo(popover_group);
render_details();
const f = gt.canvas.filter(
Snap.filter.shadow(0, 1, 1, '#666', 0.6));
self.details_box.attr({
filter: f
});
}
self.group.click((e) => {
if (self.action_completed) {
// just finished a move action, wait for a few seconds
return;
}
popover_group.selectAll('.details-wrapper')
.forEach(el => el.addClass('hide'));
self.details_box.removeClass('hide');
});
}
function render_details() {
const {x, y} = get_details_position();
self.details_box.transform(`t${x},${y}`);
self.details_box.clear();
const html = get_details_html();
const foreign_object =
Snap.parse(`<foreignObject width="5000" height="2000">
<body xmlns="http://www.w3.org/1999/xhtml">
${html}
</body>
</foreignObject>`);
self.details_box.append(foreign_object);
}
function get_details_html() {
// custom html in config
if(gt.config.custom_popup_html) {
const html = gt.config.custom_popup_html;
if(typeof html === 'string') {
return html;
}
if(isFunction(html)) {
return html(task);
}
}
const start_date = self.task._start.format('MMM D');
const end_date = self.task._end.format('MMM D');
const heading = `${self.task.name}: ${start_date} - ${end_date}`;
const line_1 = `Duration: ${self.duration} days`;
const line_2 = self.task.progress ? `Progress: ${self.task.progress}` : null;
const html = `
<div class="details-container">
<h5>${heading}</h5>
<p>${line_1}</p>
${
line_2 ? `<p>${line_2}</p>` : ''
}
</div>
`;
return html;
}
function get_details_position() {
return {
x: self.$bar.getEndX() + 2,
y: self.$bar.getY() - 10
};
}
function bind_resize() {
const { left, right } = get_handles();
left.drag(onmove_left, onstart, onstop_left);
right.drag(onmove_right, onstart, onstop_right);
function onmove_right(dx, dy) {
onmove_handle_right(dx, dy);
}
function onstop_right() {
onstop_handle_right();
}
function onmove_left(dx, dy) {
onmove_handle_left(dx, dy);
}
function onstop_left() {
onstop_handle_left();
}
}
function get_handles() {
return {
left: self.handle_group.select('.handle.left'),
right: self.handle_group.select('.handle.right')
};
}
function bind_drag() {
self.bar_group.drag(onmove, onstart, onstop);
}
function bind_resize_progress() {
const bar = self.$bar,
bar_progress = self.$bar_progress,
handle = self.group.select('.handle.progress');
handle && handle.drag(on_move, on_start, on_stop);
function on_move(dx, dy) {
if (dx > bar_progress.max_dx) {
dx = bar_progress.max_dx;
}
if (dx < bar_progress.min_dx) {
dx = bar_progress.min_dx;
}
bar_progress.attr('width', bar_progress.owidth + dx);
handle.attr('points', get_progress_polygon_points());
bar_progress.finaldx = dx;
}
function on_stop() {
if (!bar_progress.finaldx) return;
progress_changed();
set_action_completed();
}
function on_start() {
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();
}
}
function onstart() {
const bar = self.$bar;
bar.ox = bar.getX();
bar.oy = bar.getY();
bar.owidth = bar.getWidth();
bar.finaldx = 0;
run_method_for_dependencies('onstart');
}
self.onstart = onstart;
function onmove(dx, dy) {
const bar = self.$bar;
bar.finaldx = get_snap_position(dx);
update_bar_position({x: bar.ox + bar.finaldx});
run_method_for_dependencies('onmove', [dx, dy]);
}
self.onmove = onmove;
function onstop() {
const bar = self.$bar;
if (!bar.finaldx) return;
date_changed();
set_action_completed();
run_method_for_dependencies('onstop');
}
self.onstop = onstop;
function onmove_handle_left(dx, dy) {
const bar = self.$bar;
bar.finaldx = get_snap_position(dx);
update_bar_position({
x: bar.ox + bar.finaldx,
width: bar.owidth - bar.finaldx
});
run_method_for_dependencies('onmove', [dx, dy]);
}
self.onmove_handle_left = onmove_handle_left;
function onstop_handle_left() {
const bar = self.$bar;
if (bar.finaldx) date_changed();
set_action_completed();
run_method_for_dependencies('onstop');
}
self.onstop_handle_left = onstop_handle_left;
function run_method_for_dependencies(fn, args) {
const dm = gt.dependency_map;
if (dm[self.task.id]) {
for (let deptask of dm[self.task.id]) {
const dt = gt.get_bar(deptask);
dt[fn].apply(dt, args);
}
}
}
function onmove_handle_right(dx, dy) {
const bar = self.$bar;
bar.finaldx = get_snap_position(dx);
update_bar_position({width: bar.owidth + bar.finaldx});
}
function onstop_handle_right() {
const bar = self.$bar;
if (bar.finaldx) date_changed();
set_action_completed();
}
function update_bar_position({x = null, width = null}) {
const bar = self.$bar;
if (x) {
// get all x values of parent task
const xs = task.dependencies.map(dep => {
return gt.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;
}
update_attr(bar, 'x', x);
}
if (width && width >= gt.config.column_width) {
update_attr(bar, 'width', width);
}
update_label_position();
update_handle_position();
update_progressbar_position();
update_arrow_position();
update_details_position();
}
function setup_click_event() {
self.group.click(function () {
if (self.action_completed) {
// just finished a move action, wait for a few seconds
return;
}
if (self.group.hasClass('active')) {
gt.trigger_event('click', [self.task]);
}
gt.unselect_all();
self.group.toggleClass('active');
});
}
function date_changed() {
const { new_start_date, new_end_date } = compute_start_end_date();
self.task._start = new_start_date;
self.task._end = new_end_date;
render_details();
gt.trigger_event('date_change',
[self.task, new_start_date, new_end_date]);
}
function progress_changed() {
const new_progress = compute_progress();
self.task.progress = new_progress;
render_details();
gt.trigger_event('progress_change',
[self.task, new_progress]);
}
function set_action_completed() {
self.action_completed = true;
setTimeout(() => self.action_completed = false, 2000);
}
function compute_start_end_date() {
const bar = self.$bar;
const x_in_units = bar.getX() / gt.config.column_width;
const new_start_date = gt.gantt_start.clone().add(x_in_units * gt.config.step, 'hours');
const width_in_units = bar.getWidth() / gt.config.column_width;
const new_end_date = new_start_date.clone().add(width_in_units * gt.config.step, 'hours');
// lets say duration is 2 days
// start_date = May 24 00:00:00
// end_date = May 24 + 2 days = May 26 (incorrect)
// so subtract 1 second so that
// end_date = May 25 23:59:59
new_end_date.add('-1', 'seconds');
return { new_start_date, new_end_date };
}
function compute_progress() {
const progress = self.$bar_progress.getWidth() / self.$bar.getWidth() * 100;
return parseInt(progress, 10);
}
function compute_x() {
let x = self.task._start.diff(gt.gantt_start, 'hours') /
gt.config.step * gt.config.column_width;
if (gt.view_is('Month')) {
x = self.task._start.diff(gt.gantt_start, 'days') *
gt.config.column_width / 30;
}
return x;
}
function compute_y() {
return gt.config.header_height + gt.config.padding +
self.task._index * (self.height + gt.config.padding);
}
function get_snap_position(dx) {
let odx = dx, rem, position;
if (gt.view_is('Week')) {
rem = dx % (gt.config.column_width / 7);
position = odx - rem +
((rem < gt.config.column_width / 14) ? 0 : gt.config.column_width / 7);
} else if (gt.view_is('Month')) {
rem = dx % (gt.config.column_width / 30);
position = odx - rem +
((rem < gt.config.column_width / 60) ? 0 : gt.config.column_width / 30);
} else {
rem = dx % gt.config.column_width;
position = odx - rem +
((rem < gt.config.column_width / 2) ? 0 : gt.config.column_width);
}
return position;
}
function update_attr(element, attr, value) {
value = +value;
if (!isNaN(value)) {
element.attr(attr, value);
}
return element;
}
function update_progressbar_position() {
self.$bar_progress.attr('x', self.$bar.getX());
self.$bar_progress.attr('width', self.$bar.getWidth() * (self.task.progress / 100));
}
function update_label_position() {
const bar = self.$bar,
label = self.group.select('.bar-label');
if (label.getBBox().width > bar.getWidth()) {
label.addClass('big').attr('x', bar.getX() + bar.getWidth() + 5);
} else {
label.removeClass('big').attr('x', bar.getX() + bar.getWidth() / 2);
}
}
function update_handle_position() {
const bar = self.$bar;
self.handle_group.select('.handle.left').attr({
'x': bar.getX() + 1
});
self.handle_group.select('.handle.right').attr({
'x': bar.getEndX() - 9
});
const handle = self.group.select('.handle.progress');
handle && handle.attr('points', get_progress_polygon_points());
}
function update_arrow_position() {
for (let arrow of self.arrows) {
arrow.update();
}
}
function update_details_position() {
const {x, y} = get_details_position();
self.details_box && self.details_box.transform(`t${x},${y}`);
}
function isFunction(functionToCheck) {
var getType = {};
return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';
}
init();
return self;
import date_utils from './date_utils';
import { createSVG } from './svg_utils';
export default 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 = 3;
this.duration =
(date_utils.diff(this.task._end, this.task._start, 'hour') + 24) /
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.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
});
}
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
});
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();
this.show_details();
// this.bind_resize_progress();
}
show_details() {
this.group.onclick = e => {
if (this.action_completed) {
// just finished a move action, wait for a few seconds
return;
}
const start_date = date_utils.format(this.task._start, 'MMM D');
const end_date = date_utils.format(this.task._end, 'MMM D');
const subtitle = start_date + ' - ' + end_date;
this.gantt.show_popup({
target_element: this.$bar,
title: this.task.name,
subtitle: subtitle
});
};
}
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();
// this.update_details_position();
}
setup_click_event() {
this.group.onclick = () => {
if (this.action_completed) {
// just finished a move action, wait for a few seconds
return;
}
if (this.group.classList.contains('active')) {
this.gantt.trigger_event('click', [this.task]);
}
this.gantt.unselect_all();
this.group.classList.toggle('active');
};
}
date_changed() {
const { new_start_date, new_end_date } = this.compute_start_end_date();
this.task._start = new_start_date;
this.task._end = new_end_date;
this.gantt.trigger_event('date_change', [
this.task,
new_start_date,
new_end_date
]);
}
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), 2000);
}
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,
'hours'
);
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,
'hours'
);
// lets say duration is 2 days
// start_date = May 24 00:00:00
// end_date = May 24 + 2 days = May 26 (incorrect)
// so subtract 1 second so that
// end_date = May 25 23:59:59
date_utils.add(new_end_date, -1, 'second');
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() {
let x =
date_utils.diff(this.task._start, this.gantt.gantt_start, 'hour') /
this.gantt.options.step *
this.gantt.options.column_width;
if (this.gantt.view_is('Month')) {
x =
date_utils.diff(
this.task._start,
this.gantt.gantt_start,
'day'
) *
this.gantt.options.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();
}
}
update_details_position() {
const { x, y } = get_details_position();
this.details_box && this.details_box.transform(`t${x},${y}`);
}
}
function isFunction(functionToCheck) {
var getType = {};
return (
functionToCheck &&
getType.toString.call(functionToCheck) === '[object Function]'
);
}

View File

@ -1,568 +0,0 @@
/* global moment, Snap */
/**
* Gantt:
* element: querySelector string, HTML DOM or SVG DOM element, required
* tasks: array of tasks, required
* task: { id, name, start, end, progress, dependencies, custom_class }
* config: configuration options, optional
*/
import './gantt.scss';
import Bar from './Bar';
import Arrow from './Arrow';
export default function Gantt(element, tasks, config) {
const self = {};
function init() {
set_defaults();
// expose methods
self.change_view_mode = change_view_mode;
self.unselect_all = unselect_all;
self.view_is = view_is;
self.get_bar = get_bar;
self.trigger_event = trigger_event;
self.refresh = refresh;
// initialize with default view mode
change_view_mode(self.config.view_mode);
}
function set_defaults() {
const defaults = {
header_height: 50,
column_width: 30,
step: 24,
view_modes: [
'Quarter Day',
'Half Day',
'Day',
'Week',
'Month'
],
bar: {
height: 20
},
arrow: {
curve: 5
},
padding: 18,
view_mode: 'Day',
date_format: 'YYYY-MM-DD',
custom_popup_html: null
};
self.config = Object.assign({}, defaults, config);
reset_variables(tasks);
}
function reset_variables(tasks) {
if(typeof element === 'string') {
self.element = document.querySelector(element);
} else if (element instanceof SVGElement) {
self.element = element;
} else if (element instanceof HTMLElement) {
self.element = element.querySelector('svg');
} 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');
}
self._tasks = tasks;
self._bars = [];
self._arrows = [];
self.element_groups = {};
}
function refresh(updated_tasks) {
reset_variables(updated_tasks);
change_view_mode(self.config.view_mode);
}
function change_view_mode(mode) {
set_scale(mode);
prepare();
render();
// fire viewmode_change event
trigger_event('view_change', [mode]);
}
function prepare() {
prepare_tasks();
prepare_dependencies();
prepare_dates();
prepare_canvas();
}
function prepare_tasks() {
// prepare tasks
self.tasks = self._tasks.map((task, i) => {
// momentify
task._start = moment(task.start, self.config.date_format);
task._end = moment(task.end, self.config.date_format);
// make task invalid if duration too large
if(task._end.diff(task._start, 'years') > 10) {
task.end = null;
}
// cache index
task._index = i;
// invalid dates
if(!task.start && !task.end) {
task._start = moment().startOf('day');
task._end = moment().startOf('day').add(2, 'days');
}
if(!task.start && task.end) {
task._start = task._end.clone().add(-2, 'days');
}
if(task.start && !task.end) {
task._end = task._start.clone().add(2, 'days');
}
// 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;
});
}
function prepare_dependencies() {
self.dependency_map = {};
for(let t of self.tasks) {
for(let d of t.dependencies) {
self.dependency_map[d] = self.dependency_map[d] || [];
self.dependency_map[d].push(t.id);
}
}
}
function prepare_dates() {
self.gantt_start = self.gantt_end = null;
for(let task of self.tasks) {
// set global start and end date
if(!self.gantt_start || task._start < self.gantt_start) {
self.gantt_start = task._start;
}
if(!self.gantt_end || task._end > self.gantt_end) {
self.gantt_end = task._end;
}
}
set_gantt_dates();
setup_dates();
}
function prepare_canvas() {
if(self.canvas) return;
self.canvas = Snap(self.element).addClass('gantt');
}
function render() {
clear();
setup_groups();
make_grid();
make_dates();
make_bars();
make_arrows();
map_arrows_on_bars();
set_width();
set_scroll_position();
bind_grid_click();
}
function clear() {
self.canvas.clear();
self._bars = [];
self._arrows = [];
}
function set_gantt_dates() {
if(view_is(['Quarter Day', 'Half Day'])) {
self.gantt_start = self.gantt_start.clone().subtract(7, 'day');
self.gantt_end = self.gantt_end.clone().add(7, 'day');
} else if(view_is('Month')) {
self.gantt_start = self.gantt_start.clone().startOf('year');
self.gantt_end = self.gantt_end.clone().endOf('month').add(1, 'year');
} else {
self.gantt_start = self.gantt_start.clone().startOf('month').subtract(1, 'month');
self.gantt_end = self.gantt_end.clone().endOf('month').add(1, 'month');
}
}
function setup_dates() {
self.dates = [];
let cur_date = null;
while(cur_date === null || cur_date < self.gantt_end) {
if(!cur_date) {
cur_date = self.gantt_start.clone();
} else {
cur_date = view_is('Month') ?
cur_date.clone().add(1, 'month') :
cur_date.clone().add(self.config.step, 'hours');
}
self.dates.push(cur_date);
}
}
function setup_groups() {
const groups = ['grid', 'date', 'arrow', 'progress', 'bar', 'details'];
// make group layers
for(let group of groups) {
self.element_groups[group] = self.canvas.group().attr({'id': group});
}
}
function set_scale(scale) {
self.config.view_mode = scale;
if(scale === 'Day') {
self.config.step = 24;
self.config.column_width = 38;
} else if(scale === 'Half Day') {
self.config.step = 24 / 2;
self.config.column_width = 38;
} else if(scale === 'Quarter Day') {
self.config.step = 24 / 4;
self.config.column_width = 38;
} else if(scale === 'Week') {
self.config.step = 24 * 7;
self.config.column_width = 140;
} else if(scale === 'Month') {
self.config.step = 24 * 30;
self.config.column_width = 120;
}
}
function set_width() {
const cur_width = self.canvas.node.getBoundingClientRect().width;
const actual_width = self.canvas.select('#grid .grid-row').attr('width');
if(cur_width < actual_width) {
self.canvas.attr('width', actual_width);
}
}
function set_scroll_position() {
const parent_element = self.element.parentElement;
if(!parent_element) return;
const scroll_pos = get_min_date().diff(self.gantt_start, 'hours') /
self.config.step * self.config.column_width - self.config.column_width;
parent_element.scrollLeft = scroll_pos;
}
function get_min_date() {
const task = self.tasks.reduce((acc, curr) => {
return curr._start.isSameOrBefore(acc._start) ? curr : acc;
});
return task._start;
}
function make_grid() {
make_grid_background();
make_grid_rows();
make_grid_header();
make_grid_ticks();
make_grid_highlights();
}
function make_grid_background() {
const grid_width = self.dates.length * self.config.column_width,
grid_height = self.config.header_height + self.config.padding +
(self.config.bar.height + self.config.padding) * self.tasks.length;
self.canvas.rect(0, 0, grid_width, grid_height)
.addClass('grid-background')
.appendTo(self.element_groups.grid);
self.canvas.attr({
height: grid_height + self.config.padding + 100,
width: '100%'
});
}
function make_grid_header() {
const header_width = self.dates.length * self.config.column_width,
header_height = self.config.header_height + 10;
self.canvas.rect(0, 0, header_width, header_height)
.addClass('grid-header')
.appendTo(self.element_groups.grid);
}
function make_grid_rows() {
const rows = self.canvas.group().appendTo(self.element_groups.grid),
lines = self.canvas.group().appendTo(self.element_groups.grid),
row_width = self.dates.length * self.config.column_width,
row_height = self.config.bar.height + self.config.padding;
let row_y = self.config.header_height + self.config.padding / 2;
for(let task of self.tasks) { // eslint-disable-line
self.canvas.rect(0, row_y, row_width, row_height)
.addClass('grid-row')
.appendTo(rows);
self.canvas.line(0, row_y + row_height, row_width, row_y + row_height)
.addClass('row-line')
.appendTo(lines);
row_y += self.config.bar.height + self.config.padding;
}
}
function make_grid_ticks() {
let tick_x = 0,
tick_y = self.config.header_height + self.config.padding / 2,
tick_height = (self.config.bar.height + self.config.padding) * self.tasks.length;
for(let date of self.dates) {
let tick_class = 'tick';
// thick tick for monday
if(view_is('Day') && date.day() === 1) {
tick_class += ' thick';
}
// thick tick for first week
if(view_is('Week') && date.date() >= 1 && date.date() < 8) {
tick_class += ' thick';
}
// thick ticks for quarters
if(view_is('Month') && date.month() % 3 === 0) {
tick_class += ' thick';
}
self.canvas.path(Snap.format('M {x} {y} v {height}', {
x: tick_x,
y: tick_y,
height: tick_height
}))
.addClass(tick_class)
.appendTo(self.element_groups.grid);
if(view_is('Month')) {
tick_x += date.daysInMonth() * self.config.column_width / 30;
} else {
tick_x += self.config.column_width;
}
}
}
function make_grid_highlights() {
// highlight today's date
if(view_is('Day')) {
const x = moment().startOf('day').diff(self.gantt_start, 'hours') /
self.config.step * self.config.column_width;
const y = 0;
const width = self.config.column_width;
const height = (self.config.bar.height + self.config.padding) * self.tasks.length +
self.config.header_height + self.config.padding / 2;
self.canvas.rect(x, y, width, height)
.addClass('today-highlight')
.appendTo(self.element_groups.grid);
}
}
function make_dates() {
for(let date of get_dates_to_draw()) {
self.canvas.text(date.lower_x, date.lower_y, date.lower_text)
.addClass('lower-text')
.appendTo(self.element_groups.date);
if(date.upper_text) {
const $upper_text = self.canvas.text(date.upper_x, date.upper_y, date.upper_text)
.addClass('upper-text')
.appendTo(self.element_groups.date);
// remove out-of-bound dates
if($upper_text.getBBox().x2 > self.element_groups.grid.getBBox().width) {
$upper_text.remove();
}
}
}
}
function get_dates_to_draw() {
let last_date = null;
const dates = self.dates.map((date, i) => {
const d = get_date_info(date, last_date, i);
last_date = date;
return d;
});
return dates;
}
function get_date_info(date, last_date, i) {
if(!last_date) {
last_date = date.clone().add(1, 'year');
}
const date_text = {
'Quarter Day_lower': date.format('HH'),
'Half Day_lower': date.format('HH'),
'Day_lower': date.date() !== last_date.date() ? date.format('D') : '',
'Week_lower': date.month() !== last_date.month() ?
date.format('D MMM') : date.format('D'),
'Month_lower': date.format('MMMM'),
'Quarter Day_upper': date.date() !== last_date.date() ? date.format('D MMM') : '',
'Half Day_upper': date.date() !== last_date.date() ?
date.month() !== last_date.month() ?
date.format('D MMM') : date.format('D') : '',
'Day_upper': date.month() !== last_date.month() ? date.format('MMMM') : '',
'Week_upper': date.month() !== last_date.month() ? date.format('MMMM') : '',
'Month_upper': date.year() !== last_date.year() ? date.format('YYYY') : ''
};
const base_pos = {
x: i * self.config.column_width,
lower_y: self.config.header_height,
upper_y: self.config.header_height - 25
};
const x_pos = {
'Quarter Day_lower': (self.config.column_width * 4) / 2,
'Quarter Day_upper': 0,
'Half Day_lower': (self.config.column_width * 2) / 2,
'Half Day_upper': 0,
'Day_lower': self.config.column_width / 2,
'Day_upper': (self.config.column_width * 30) / 2,
'Week_lower': 0,
'Week_upper': (self.config.column_width * 4) / 2,
'Month_lower': self.config.column_width / 2,
'Month_upper': (self.config.column_width * 12) / 2
};
return {
upper_text: date_text[`${self.config.view_mode}_upper`],
lower_text: date_text[`${self.config.view_mode}_lower`],
upper_x: base_pos.x + x_pos[`${self.config.view_mode}_upper`],
upper_y: base_pos.upper_y,
lower_x: base_pos.x + x_pos[`${self.config.view_mode}_lower`],
lower_y: base_pos.lower_y
};
}
function make_arrows() {
self._arrows = [];
for(let task of self.tasks) {
let arrows = [];
arrows = task.dependencies.map(dep => {
const dependency = get_task(dep);
if(!dependency) return;
const arrow = Arrow(
self, // gt
self._bars[dependency._index], // from_task
self._bars[task._index] // to_task
);
self.element_groups.arrow.add(arrow.element);
return arrow; // eslint-disable-line
}).filter(arr => arr); // filter falsy values
self._arrows = self._arrows.concat(arrows);
}
}
function make_bars() {
self._bars = self.tasks.map((task) => {
const bar = Bar(self, task);
self.element_groups.bar.add(bar.group);
return bar;
});
}
function map_arrows_on_bars() {
for(let bar of self._bars) {
bar.arrows = self._arrows.filter(arrow => {
return (arrow.from_task.task.id === bar.task.id) ||
(arrow.to_task.task.id === bar.task.id);
});
}
}
function bind_grid_click() {
self.element_groups.grid.click(() => {
unselect_all();
self.element_groups.details
.selectAll('.details-wrapper')
.forEach(el => el.addClass('hide'));
});
}
function unselect_all() {
self.canvas.selectAll('.bar-wrapper').forEach(el => {
el.removeClass('active');
});
}
function view_is(modes) {
if (typeof modes === 'string') {
return self.config.view_mode === modes;
} else if(Array.isArray(modes)) {
for (let mode of modes) {
if(self.config.view_mode === mode) return true;
}
return false;
}
}
function get_task(id) {
return self.tasks.find((task) => {
return task.id === id;
});
}
function get_bar(id) {
return self._bars.find((bar) => {
return bar.task.id === id;
});
}
function generate_id(task) {
return task.name + '_' + Math.random().toString(36).slice(2, 12);
}
function trigger_event(event, args) {
if(self.config['on_' + event]) {
self.config['on_' + event].apply(null, args);
}
}
init();
return self;
}

222
src/date_utils.js Normal file
View File

@ -0,0 +1,222 @@
const YEAR = 'year';
const MONTH = 'month';
const DAY = 'day';
const HOUR = 'hour';
const MINUTE = 'minute';
const SECOND = 'second';
const MILLISECOND = 'millisecond';
const month_names = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
export default {
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) {
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;
}
return padStart(val + '', 2, '0');
});
const date_string = `${vals[0]}-${vals[1]}-${vals[2]}`;
const time_string = `${vals[3]}:${vals[4]}:${vals[5]}`;
return date_string + (with_time ? ' ' + time_string : '');
},
format(date, format_string = 'YYYY-MM-DD HH:mm:ss') {
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],
D: values[2],
MMMM: month_names[+values[1]],
MMM: month_names[+values[1]]
};
let str = format_string;
Object.keys(format_map)
.sort((a, b) => b.length - a.length) // big string first
.forEach(key => {
str = str.replace(key, format_map[key]);
});
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);
}
}

View File

@ -11,7 +11,6 @@ $blue: #a3a3ff;
$handle-color: #ddd;
.gantt {
.grid-background {
fill: none;
}
@ -41,7 +40,7 @@ $handle-color: #ddd;
opacity: 0.5;
}
#arrow {
.arrow {
fill: none;
stroke: $text-muted;
stroke-width: 1.4;
@ -52,6 +51,7 @@ $handle-color: #ddd;
stroke: $bar-stroke;
stroke-width: 0;
transition: stroke-width .3s ease;
user-select: none;
}
.bar-progress {
fill: $blue;
@ -119,34 +119,40 @@ $handle-color: #ddd;
fill: $text-color;
}
#details .details-container {
background: #fff;
display: inline-block;
padding: 12px;
h5, p {
margin: 0;
}
h5 {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
color: $text-light;
}
p {
font-size: 12px;
margin-bottom: 6px;
color: $text-muted;
}
p:last-child {
margin-bottom: 0;
}
}
.hide {
display: none;
}
}
.gantt-container {
position: relative;
font-size: 12px;
.popup-wrapper {
position: absolute;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.8);
padding: 0;
color: #959da5;
border-radius: 3px;
.title {
border-bottom: 3px solid $blue;
padding: 10px;
}
.subtitle {
padding: 10px;
color: #dfe2e5;
}
.pointer {
position: absolute;
height: 5px;
margin: 0 0 0 -5px;
border: 5px solid transparent;
border-top-color: rgba(0, 0, 0, 0.8);
}
}
}

848
src/index.js Normal file
View File

@ -0,0 +1,848 @@
import date_utils from './date_utils';
import { $, createSVG } from './svg_utils';
import Bar from './bar';
import Arrow from './arrow';
import Popup from './popup';
import './gantt.scss';
export default 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) {
if (typeof element === 'string') {
element = document.querySelector(element);
}
if (!(element instanceof HTMLElement)) {
throw new Error('Invalid argument passed for element');
}
// parent div element
this.$container = document.createElement('div');
this.$container.classList.add('gantt-container');
element.appendChild(this.$container);
// parent svg element
this.$svg = createSVG('svg', {
append_to: this.$container,
class: 'gantt'
});
// popup wrapper
this.popup_wrapper = document.createElement('div');
this.popup_wrapper.classList.add('popup-wrapper');
this.$svg.parentElement.appendChild(this.popup_wrapper);
}
setup_options(options) {
const default_options = {
header_height: 50,
column_width: 30,
step: 24,
view_modes: ['Quarter Day', 'Half Day', 'Day', 'Week', 'Month'],
bar: {
height: 20
},
arrow: {
curve: 5
},
padding: 18,
view_mode: 'Day',
date_format: 'YYYY-MM-DD',
custom_popup_html: null
};
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');
}
// 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 === 'Day') {
this.options.step = 24;
this.options.column_width = 38;
} else if (view_mode === 'Half Day') {
this.options.step = 24 / 2;
this.options.column_width = 38;
} else if (view_mode === 'Quarter Day') {
this.options.step = 24 / 4;
this.options.column_width = 38;
} else if (view_mode === 'Week') {
this.options.step = 24 * 7;
this.options.column_width = 140;
} else if (view_mode === 'Month') {
this.options.step = 24 * 30;
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;
}
}
// add date padding on both sides
if (this.view_is(['Quarter Day', '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('Month')) {
this.gantt_start = date_utils.start_of(this.gantt_start, 'year');
this.gantt_end = date_utils.add(this.gantt_end, 1, '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 {
cur_date = this.view_is('Month')
? date_utils.add(cur_date, 1, 'month')
: 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('Day') && date.getDate() === 1) {
tick_class += ' thick';
}
// thick tick for first week
if (
this.view_is('Week') &&
date.getDate() >= 1 &&
date.getDate() < 8
) {
tick_class += ' thick';
}
// thick ticks for quarters
if (this.view_is('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('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('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'),
'Half Day_lower': date_utils.format(date, 'HH'),
Day_lower:
date.getDate() !== last_date.getDate()
? date_utils.format(date, 'D')
: '',
Week_lower:
date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'D MMM')
: date_utils.format(date, 'D'),
Month_lower: date_utils.format(date, 'MMMM'),
'Quarter Day_upper':
date.getDate() !== last_date.getDate()
? date_utils.format(date, 'D MMM')
: '',
'Half Day_upper':
date.getDate() !== last_date.getDate()
? date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'D MMM')
: date_utils.format(date, 'D')
: '',
Day_upper:
date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'MMMM')
: '',
Week_upper:
date.getMonth() !== last_date.getMonth()
? date_utils.format(date, 'MMMM')
: '',
Month_upper:
date.getFullYear() !== last_date.getFullYear()
? date_utils.format(date, 'YYYY')
: ''
};
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
};
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() {
this.layers.grid.onclick = () => {
this.unselect_all();
this.popup && this.popup.hide();
};
}
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
function action_in_progress() {
return is_dragging || is_resizing_left || is_resizing_right;
}
$.on(
this.layers.bar,
'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;
}
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));
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 => {
is_dragging = false;
is_resizing_left = false;
is_resizing_right = false;
});
$.on(this.$svg, 'mouseup', e => {
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('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('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.popup.show(options);
}
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 = '';
}
}
function generate_id(task) {
return (
task.name +
'_' +
Math.random()
.toString(36)
.slice(2, 12)
);
}

68
src/popup.js Normal file
View File

@ -0,0 +1,68 @@
export default class Popup {
constructor(parent) {
this.parent = parent;
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;
// 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 -
this.title.clientHeight / 2 +
position_meta.height / 2 +
'px';
this.pointer.style.transform = 'rotateZ(90deg)';
this.pointer.style.left = '-7px';
this.pointer.style.top =
this.title.clientHeight / 2 -
this.pointer.getBoundingClientRect().height +
'px';
}
// show
this.parent.style.opacity = 1;
}
hide() {
this.parent.style.opacity = 0;
}
}

119
src/svg_utils.js Normal file
View File

@ -0,0 +1,119 @@
export function $(expr, con) {
return typeof expr === 'string'
? (con || document).querySelector(expr)
: expr || null;
}
export 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;
}
export 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.3s',
begin = '0s'
) {
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
});
svgElement.appendChild(animateElement);
return svgElement;
}
$.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);
};

75
tests/date_utils.test.js Normal file
View File

@ -0,0 +1,75 @@
import date_utils from '../src/date_utils';
test('Parse: parses string date', () => {
const date = date_utils.parse('2017-09-09');
expect(date.getDate()).toBe(9);
expect(date.getMonth()).toBe(8);
expect(date.getFullYear()).toBe(2017);
});
test('Parse: parses string datetime', () => {
const date = date_utils.parse('2017-08-27 16:08:34');
expect(date.getFullYear()).toBe(2017);
expect(date.getMonth()).toBe(7);
expect(date.getDate()).toBe(27);
expect(date.getHours()).toBe(16);
expect(date.getMinutes()).toBe(8);
expect(date.getSeconds()).toBe(34);
});
test('Format: converts date object to string', () => {
const date = new Date('2017-09-18');
expect(date_utils.to_string(date)).toBe('2017-09-18');
});
test('Parse: returns Date Object as is', () => {
const d = new Date();
const date = date_utils.parse(d);
expect(d).toBe(date);
});
test('Diff: returns diff between 2 date objects', () => {
const a = date_utils.parse('2017-09-08');
const b = date_utils.parse('2017-06-07');
expect(date_utils.diff(a, b, 'day')).toBe(93);
expect(date_utils.diff(a, b, 'month')).toBe(3);
expect(date_utils.diff(a, b, 'year')).toBe(0);
});
test('StartOf', () => {
const date = date_utils.parse('2017-08-12 15:07:34');
const start_of_minute = date_utils.start_of(date, 'minute');
expect(date_utils.to_string(start_of_minute, true)).toBe(
'2017-08-12 15:07:00'
);
const start_of_hour = date_utils.start_of(date, 'hour');
expect(date_utils.to_string(start_of_hour, true)).toBe(
'2017-08-12 15:00:00'
);
const start_of_day = date_utils.start_of(date, 'day');
expect(date_utils.to_string(start_of_day, true)).toBe(
'2017-08-12 00:00:00'
);
const start_of_month = date_utils.start_of(date, 'month');
expect(date_utils.to_string(start_of_month, true)).toBe(
'2017-08-01 00:00:00'
);
const start_of_year = date_utils.start_of(date, 'year');
expect(date_utils.to_string(start_of_year, true)).toBe(
'2017-01-01 00:00:00'
);
});
test('format', () => {
const date = date_utils.parse('2017-08-12 15:07:23');
expect(date_utils.format(date, 'YYYY-MM-DD')).toBe('2017-08-12');
});

View File

@ -1,52 +0,0 @@
var webpack = require('webpack');
var UglifyJsPlugin = webpack.optimize.UglifyJsPlugin;
var path = require('path');
var env = require('yargs').argv.mode;
var libraryName = 'frappe-gantt';
var plugins = [], outputFile;
if (env === 'build') {
plugins.push(new UglifyJsPlugin({ minimize: true }));
outputFile = libraryName + '.min.js';
} else {
outputFile = libraryName + '.js';
}
var config = {
entry: __dirname + '/src/Gantt.js',
devtool: 'source-map',
output: {
path: __dirname + '/dist',
filename: outputFile,
library: 'Gantt',
libraryTarget: 'umd',
umdNamedDefine: true
},
module: {
loaders: [
{
test: /(\.jsx|\.js)$/,
loader: 'babel',
exclude: /(node_modules|bower_components)/
},
{
test: /(\.jsx|\.js)$/,
loader: 'eslint-loader',
exclude: /node_modules/
},
{
test: /\.scss$/,
loaders: [ 'style', 'css?sourceMap', 'sass?sourceMap' ]
}
]
},
resolve: {
root: path.resolve('./src'),
extensions: ['', '.js']
},
plugins: plugins
};
module.exports = config;

3869
yarn.lock Normal file

File diff suppressed because it is too large Load Diff