diff --git a/.eslintrc b/.eslintrc
deleted file mode 100644
index 8d09c5f..0000000
--- a/.eslintrc
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": ["plugin:prettier/recommended"],
- "parserOptions": {
- "ecmaVersion": 6,
- "sourceType": "module"
- }
-}
diff --git a/.github/gantt-logo.jpg b/.github/gantt-logo.jpg
new file mode 100644
index 0000000..9cad511
Binary files /dev/null and b/.github/gantt-logo.jpg differ
diff --git a/.github/hero-image.png b/.github/hero-image.png
new file mode 100644
index 0000000..39b98d3
Binary files /dev/null and b/.github/hero-image.png differ
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index aae0dd8..16f0dfc 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,7 +1,7 @@
name: Publish on NPM
on:
push:
- branches: [master]
+ branches: [release]
jobs:
publish:
diff --git a/.gitignore b/.gitignore
index faae2ff..691462f 100755
--- a/.gitignore
+++ b/.gitignore
@@ -31,4 +31,4 @@ node_modules
.DS_Store
gh-pages
-feedback.md
+feedback*.md
diff --git a/README.md b/README.md
index 1e30f69..5195f34 100644
--- a/README.md
+++ b/README.md
@@ -1,123 +1,158 @@
-
-
-
Frappe Gantt
-
-
A simple, interactive, modern gantt chart library for the web
-
- View the demo »
-
-
+
+
+
Frappe Gantt
+
+**A modern, configurable, Gantt library for the web.**
-
-
-
-
-
+
-### Install
+## Frappe Gantt
+Gantt charts are bar charts that visually illustrate a project's tasks, schedule, and dependencies. With Frappe Gantt, you can build beautiful, customizable, Gantt charts with ease.
-```
+You can use it anywhere from hobby projects to tracking the goals of your team at the worksplace.
+
+[ERPNext](https://erpnext.com/) uses Frappe Gantt.
+
+
+### Motivation
+We needed a Gantt View for ERPNext. Surprisingly, we couldn't find a visually appealing Gantt library that was open source - so we decided to build it. Initially, the design was heavily inspired by Google Gantt and DHTMLX.
+
+
+### Key Features
+- **Customizable Views**: customize the timeline based on various time periods - day, hour, or year, you have it. You can also create your own views.
+- **Ignore Periods**: exclude weekends and other holidays from your tasks' progress calculation.
+- **Configure Anything**: spacing, edit access, labels, you can control it all. Change both the style and functionality to meet your needs.
+- **Multi-lingual Support**: suitable for companies with an international base.
+
+## Usage
+
+Install with:
+```bash
npm install frappe-gantt
```
-### Usage
-
Include it in your HTML:
-```
-
+```html
+
```
Or from the CDN:
-```
+```html
```
-And start hacking:
+Start using Gantt:
```js
-var tasks = [
+let tasks = [
{
- id: 'Task 1',
+ id: '1',
name: 'Redesign website',
start: '2016-12-28',
end: '2016-12-31',
- progress: 20,
- dependencies: 'Task 2, Task 3',
- custom_class: 'bar-milestone' // optional
+ progress: 20
},
...
]
-var gantt = new Gantt("#gantt", tasks);
+let gantt = new Gantt("#gantt", tasks);
```
-You can also pass various options to the Gantt constructor:
+### Configuration
+Frappe Gantt offers a wide range of options to customize your chart.
-```js
-var gantt = new Gantt('#gantt', tasks, {
- header_height: 50,
- column_width: 30,
- step: 24,
- view_modes: ['Quarter Day', 'Half Day', 'Day', 'Week', 'Month'],
- bar_height: 20,
- bar_corner_radius: 3,
- arrow_curve: 5,
- padding: 18,
- view_mode: 'Day',
- date_format: 'YYYY-MM-DD',
- language: 'en', // or 'es', 'it', 'ru', 'ptBr', 'fr', 'tr', 'zh', 'de', 'hu'
- popup: null,
-});
-```
-You can add `dark` class to the container element to apply dark theme.
+| **Option** | **Description** | **Possible Values** | **Default** |
+|---------------------------|---------------------------------------------------------------------------------|----------------------------------------------------|------------------------------------|
+| `arrow_curve` | Curve radius of arrows connecting dependencies. | Any positive integer. | `5` |
+| `auto_move_label` | Move task labels when user scrolls horizontally. | `true`, `false` | `false` |
+| `bar_corner_radius` | Radius of the task bar corners (in pixels). | Any positive integer. | `3` |
+| `bar_height` | Height of task bars (in pixels). | Any positive integer. | `30` |
+| `container_height` | Height of the container. | `auto` - dynamic container height to fit all tasks - _or_ any positive integer (for pixels). | `auto` |
+| `column_width` | Width of each column in the timeline. | Any positive integer. | 45 |
+| `date_format` | Format for displaying dates. | Any valid JS date format string. | `YYYY-MM-DD` |
+| `upper_header_height` | Height of the upper header in the timeline (in pixels). | Any positive integer. | `45` |
+| `lower_header_height` | Height of the lower header in the timeline (in pixels). | Any positive integer. | `30` |
+| `snap_at` | Snap tasks at particular intervel while resizing or dragging. | Any _interval_ (see below) | `1d` |
+| `infinite_padding` | Whether to extend timeline infinitely when user scrolls. | `true`, `false` | `true` |
+| `holidays` | Highlighted holidays on the timeline. | Object mapping CSS colors to holiday types. Types can either be a) 'weekend', or b) array of _strings_ or _date objects_ or _objects_ in the format `{date: ..., label: ...}` | `{ 'var(--g-weekend-highlight-color)': 'weekend' }` |
+| `ignore` | Ignored areas in the rendering | `weekend` _or_ Array of strings or date objects (`weekend` can be present to the array also). | `[]` |
+| `language` | Language for localization. | ISO 639-1 codes like `en`, `fr`, `es`. | `en` |
+| `lines` | Determines which grid lines to display. | `none` for no lines, `vertical` for only vertical lines, `horizontal` for only horizontal lines, `both` for complete grid. | `both` |
+| `move_dependencies` | Whether moving a task automatically moves its dependencies. | `true`, `false` | `true` |
+| `padding` | Padding around task bars (in pixels). | Any positive integer. | `18` |
+| `popup_on` | Event to trigger the popup display. | `click` _or_ `hover` | `click` |
+| `readonly_progress` | Disables editing task progress. | `true`, `false` | `false` |
+| `readonly_dates` | Disables editing task dates. | `true`, `false` | `false` |
+| `readonly` | Disables all editing features. | `true`, `false` | `false` |
+| `scroll_to` | Determines the starting point when chart is rendered. | `today`, `start`, `end`, or a date string. | `today` |
+| `show_expected_progress` | Shows expected progress for tasks. | `true`, `false` | `false` |
+| `today_button` | Adds a button to navigate to today’s date. | `true`, `false` | `true` |
+| `view_mode` | The initial view mode of the Gantt chart. | `Day`, `Week`, `Month`, `Year`. | `Day` |
+| `view_mode_select` | Allows selecting the view mode from a dropdown. | `true`, `false` | `false` |
-```html
-
-```
+Apart from these ones, two options - `popup` and `view_modes` (plural, not singular) - are available. They have "sub"-APIs, and thus are listed separately.
-### Contributing
+#### View Mode Configuration
+The `view_modes` option determines all the available view modes for the chart. It should be an array of objects.
+Each object can have the following properties:
+- `name` (string) - the name of view mode.
+- `padding` (interval) - the time above.
+- `step` - the interval of each column
+- `lower_text` (date format string _or_ function) - the format for text in lower header. Blank string for none. The function takes in `currentDate`, `previousDate`, and `lang`, and should return a string.
+- `upper_text` (date format string _or_ function) - the format for text in upper header. Blank string for none. The function takes in `currentDate`, `previousDate`, and `lang`, and should return a string.
+- `upper_text_frequency` (number) - how often the upper text has a value. Utilized in internal calculation to improve performance.
+- `thick_line` (function) - takes in `currentDate`, returns Boolean determining whether the line for that date should be thicker than the others.
+
+Three other options allow you to override general configuration for this view mode alone:
+- `date_format`
+- `column_width`
+- `snap_at`
+For details, see the above table.
+
+#### Popup Configuration
+`popup` is a function. If it returns
+- `false`, there will be no popup.
+- `undefined`, the popup will be rendered based on manipulation within the function
+- a HTML string, the popup will be that string.
+
+The function receives one object as an argument, containing:
+- `task` - the task as an object
+- `chart` - the entire Gantt chart
+- `get_title`, `get_subtitle`, `get_details` (functions) - get the relevant section as a HTML node.
+- `set_title`, `set_subtitle`, `set_details` (functions) - take in the HTML of the relevant section
+- `add_action` (function) - accepts two parameters, `html` and `func` - respectively determining the HTML of the action and the callback when the action is pressed.
+
+### API
+Frappe Gantt exposes a few helpful methods for you to interact with the chart:
+
+| **Name** | **Description** | **Parameters** |
+|---------------------------|---------------------------------------------------------------------------------|------------------------------------------|
+| `.update_options` | Re-renders the chart after updating specific options. | `new_options` - object containing new options. |
+| `.change_view_mode` | Updates the view mode. | `view_mode` - Name of view mode _or_ view mode object (see above) and `maintain_pos` - whether to go back to current scroll position after rerendering, defaults to `false`. |
+| `.scroll_current` | Scrolls to the current date | No parameters. |
+| `.update_task` | Re-renders a specific task bar alone | `task_id` - id of task and `new_details` - object containing the task properties to be updated. |
+
+## Development Setup
If you want to contribute enhancements or fixes:
1. Clone this repo.
-2. `cd` into project directory
-3. `yarn`
-4. `yarn run dev`
-5. Open `index.html` in your browser, make your code changes and test them.
+2. `cd` into project directory.
+3. Run `pnpm i` to install dependencies.
+4. `pnpm run build` to build files - or `pnpm run build-dev` to build and watch for changes.
+5. Open `index.html` in your browser.
+6. Make your code changes and test them.
-### Publishing
-
-If you have publishing rights (Frappe Team), follow these steps to publish a new version.
-
-Assuming the last commit (or a couple of commits) were enhancements or fixes,
-
-1. Run `yarn build`
-
- This will generate files in the `dist/` folder. These files need to be committed.
-
-1. Run `yarn publish`
-1. Type the new version at the prompt
-
- Depending on the type of change, you can either bump the patch version or the minor version.
- For e.g.,
-
- ```
- 0.5.0 -> 0.6.0 (minor version bump)
- 0.5.0 -> 0.5.1 (patch version bump)
- ```
-
-1. Now, there will be a commit named after the version you just entered. Include the generated files in `dist/` folder as part of this commit by running the command:
- ```
- git add dist
- git commit --amend
- git push origin master
- ```
-
-License: MIT
-
----
-
-Project maintained by [frappe](https://github.com/frappe)
+
+
+
diff --git a/builder/demo.css b/builder/demo.css
new file mode 100644
index 0000000..89f79b9
--- /dev/null
+++ b/builder/demo.css
@@ -0,0 +1,115 @@
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 50px;
+ height: 20px;
+ float: right;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ddd;
+ -webkit-transition: 0.2s;
+ transition: 0.2s;
+ border: 1px solid #37352f;
+ scale: 0.75;
+}
+
+.slider:before {
+ position: absolute;
+ content: '';
+ height: 12px;
+ width: 12px;
+ left: 4px;
+ bottom: 3px;
+ background-color: white;
+ -webkit-transition: 0.2s;
+ transition: 0.2s;
+}
+
+input:checked + .slider {
+ background-color: #7c7c7c;
+ border-color: #7c7c7c;
+}
+
+input:focus + .slider {
+ box-shadow: none;
+}
+
+input:checked + .slider:before {
+ -webkit-transform: translateX(28px);
+ -ms-transform: translateX(28px);
+ transform: translateX(28px);
+}
+
+.slider.round {
+ border-radius: 25px;
+}
+
+.slider.round:before {
+ border-radius: 50%;
+}
+
+.viewmode-select {
+ font-size: 100%;
+}
+
+.selected {
+ border: 1.5px solid black !important;
+}
+
+.button {
+ background: white;
+ border: 1px dotted black;
+ border-radius: 3px;
+}
+
+.button:hover {
+ background: #f4f5f6;
+ border: 1px dotted black;
+}
+
+.button div {
+ color: black;
+}
+
+.input-switch {
+ align-items: center;
+ width: 45%;
+ display: flex;
+ justify-content: space-between;
+}
+
+.input-switch label {
+ padding-right: 30px;
+ font-size: 14px;
+}
+
+.code {
+ display: block;
+ background: 0;
+ white-space: pre;
+ overflow-x: scroll;
+ max-width: 100%;
+ min-width: 100px;
+ padding: 0;
+ font-family: monospace;
+ padding-top: 0.8571429em;
+ padding-right: 1.1428571em;
+ padding-bottom: 0.8571429em;
+ padding-left: 1.1428571em;
+ background: #1f2937;
+ color: #e5e7eb;
+ border-radius: 3px;
+}
diff --git a/builder/demo.js b/builder/demo.js
new file mode 100644
index 0000000..a9f9c5f
--- /dev/null
+++ b/builder/demo.js
@@ -0,0 +1,326 @@
+const tasks = [
+ {
+ start: daysSince(-7),
+ end: daysSince(-5),
+ name: 'Initial brainstorming',
+ id: 'Task 0',
+ progress: random(),
+ },
+ {
+ start: daysSince(-3),
+ end: daysSince(1),
+ name: 'Develop wireframe',
+ id: 'Task 1',
+ progress: random(),
+ dependencies: 'Task 0',
+ },
+ {
+ start: daysSince(-1),
+ duration: '4d',
+ name: 'Client meeting',
+ id: 'Task 2',
+ progress: random(),
+ important: true,
+ },
+ {
+ start: daysSince(1),
+ duration: '7d',
+ name: 'Create prototype',
+ id: 'Task 3',
+ dependencies: 'Task 2',
+ progress: random(),
+ },
+ {
+ start: daysSince(3),
+ duration: '5d',
+ name: 'Test design with users',
+ dependencies: 'Task 2',
+ id: 'Task 4',
+ progress: random(),
+ important: true,
+ },
+ {
+ start: daysSince(5),
+ end: daysSince(10),
+ name: 'Write technical documentation',
+ id: 'Task 5',
+ progress: random(),
+ },
+ {
+ start: daysSince(8),
+ duration: '3d',
+ name: 'Prepare demo',
+ id: 'Task 6',
+ dependencies: 'Task 5',
+ progress: random(),
+ },
+ {
+ start: daysSince(10),
+ end: daysSince(12),
+ name: 'Final client review',
+ id: 'Task 7',
+ progress: 0,
+ important: true,
+ },
+ {
+ start: daysSince(14),
+ duration: '6d',
+ name: 'Implement feedback',
+ id: 'Task 8',
+ progress: 0,
+ },
+];
+
+const tasksSmall = [
+ {
+ start: daysSince(-2),
+ end: daysSince(2),
+ name: 'Redesign website',
+ id: 'Task 0',
+ progress: random(),
+ },
+ {
+ start: daysSince(3),
+ duration: '6d',
+ name: 'Write new content',
+ id: 'Task 1',
+ progress: random(),
+ important: true,
+ dependencies: 'Task 0',
+ },
+ {
+ start: daysSince(4),
+ duration: '2d',
+ name: 'Apply new styles',
+ id: 'Task 2',
+ progress: random(),
+ },
+ {
+ start: daysSince(-4),
+ end: daysSince(0),
+ name: 'Review',
+ id: 'Task 3',
+ progress: random(),
+ },
+];
+
+const tasksBlank = [
+ {
+ start: daysSince(1),
+ duration: '3d',
+ name: 'Marketing Strategy Review',
+ id: 'Task 1',
+ important: true,
+ },
+ {
+ start: daysSince(-2),
+ end: daysSince(12),
+ name: 'Mentor Sooriya',
+ id: 'Task 0',
+ },
+ {
+ start: daysSince(4),
+ end: daysSince(5),
+ name: 'Investors Meetup',
+ id: 'Task 3',
+ },
+];
+
+const HOLIDAYS = [
+ { name: 'New Years Day', date: '2025-01-01' },
+ { name: 'Republic Day', date: '2025-01-26' },
+ { name: 'Maha Shivratri', date: '2025-02-23' },
+ { name: 'Holi', date: '2025-03-11' },
+ { name: 'Mahavir Jayanthi', date: '2025-04-07' },
+ { name: 'Good Friday', date: '2025-04-10' },
+ { name: 'May Day', date: '2025-05-01' },
+ { name: 'Buddha Purnima', date: '2025-05-08' },
+ { name: 'Krishna Janmastami', date: '2025-08-14' },
+ { name: 'Independence Day', date: '2025-08-15' },
+ { name: 'Ganesh Chaturthi', date: '2025-08-23' },
+ { name: 'Id-Ul-Fitr', date: '2025-09-21' },
+ { name: 'Vijaya Dashami', date: '2025-09-28' },
+ { name: 'Mahatma Gandhi Jayanti', date: '2025-10-02' },
+ { name: 'Diwali', date: '2025-10-17' },
+ { name: 'Guru Nanak Jayanthi', date: '2025-11-02' },
+ { name: 'Christmas', date: '2025-12-25' },
+];
+
+new Gantt('#central-demo', tasks, {
+ scroll_to: daysSince(-7),
+ infinite_padding: false,
+});
+
+const sideheader = new Gantt('#sideheader', tasksSmall, {
+ scroll_to: daysSince(-20),
+ view_mode_select: true,
+ infinite_padding: false,
+});
+
+const popup = new Gantt('#popup', tasksBlank, {
+ scroll_to: daysSince(-7),
+ infinite_padding: false,
+ container_height: 350,
+ popup: (ctx) => {
+ ctx.set_title(ctx.task.name);
+ let title = ctx.get_title();
+ title.style.border = '0.5px solid black';
+ title.style.borderRadius = '1.5px';
+ title.style.padding = '3px 5px ';
+ title.style.backgroundColor = 'black';
+ title.style.opacity = '0.85';
+ title.style.color = 'white';
+ title.style.width = 'fit-content';
+ title.onclick = () => {
+ let ans = prompt('New Title: ');
+ if (ans) ctx.set_title(ans);
+ };
+ if (ctx.task.description) ctx.set_subtitle(ctx.task.description);
+ else ctx.set_subtitle('');
+
+ ctx.set_details(
+ `
Duration : ${ctx.task.actual_duration} days
Dates : ${ctx.task._start.toLocaleDateString('en-US')} - ${ctx.task._end.toLocaleDateString('en-US')}`,
+ );
+ let details = ctx.get_details();
+ details.style.lineHeight = '1.75';
+ details.style.margin = '10px 4px';
+ if (!ctx.chart.options.readonly) {
+ if (!ctx.chart.options.readonly_progress) {
+ ctx.add_action('Set Color', (task, chart) => {
+ const bar = chart.bars.find(
+ ({ task: t }) => t.id === task.id,
+ ).$bar;
+ bar.style.fill = `hsla(${~~(360 * Math.random())}, 70%, 72%, 0.8)`;
+ });
+ }
+ }
+ },
+});
+
+const holidays = new Gantt('#holidays', tasks, {
+ holidays: {
+ 'var(--g-weekend-highlight-color)': [],
+ '#fffddb': HOLIDAYS,
+ },
+ ignore: ['weekend'],
+ infinite_padding: false,
+ container_height: 350,
+ scroll_to: daysSince(-7),
+});
+
+SWITCHES = {
+ 'sideheader-form': {
+ 'toggle-today': 'Scroll to today: ',
+ 'toggle-view-mode': 'Change view mode: ',
+ },
+ 'holiday-subform': {
+ 'toggle-weekends': ['Mark weekends: ', false],
+ 'ignore-weekends': 'Exclude weekends: ',
+ },
+};
+
+for (let form of ['sideheader-form', 'holiday-form']) {
+ let formNode = document.getElementById(form);
+ let parent = formNode.parentElement;
+ parent.appendChild(formNode);
+}
+
+for (let form in SWITCHES) {
+ for (let id in SWITCHES[form]) {
+ createSwitch(form, id, SWITCHES[form][id]);
+ }
+}
+
+const UPDATES = [
+ [
+ sideheader,
+ {
+ 'toggle-today': 'today_button',
+ 'toggle-view-mode': 'view_mode_select',
+ },
+ ],
+ [
+ holidays,
+ {
+ 'toggle-weekends': (val, opts) => ({
+ holidays: {
+ '#fffddb': opts.holidays['#fffddb'],
+ 'var(--g-weekend-highlight-color)': val ? 'weekend' : [],
+ },
+ ignore: [],
+ }),
+ 'declare-holiday': (val, opts) => ({
+ holidays: {
+ '#fffddb': [...HOLIDAYS, { date: val, name: 'Kay' }],
+ 'var(--g-weekend-highlight-color)':
+ opts.holidays['var(--g-weekend-highlight-color)'],
+ },
+ }),
+ 'ignore-weekends': (val, opts) => ({
+ ignore: [
+ opts.ignore.filter((k) => k !== 'weekend')[0],
+ ...(val ? ['weekend'] : []),
+ ],
+ holidays: { '#fffddb': opts.holidays['#fffddb'] },
+ }),
+ 'declare-ignore': (val, opts) => ({
+ ignore: [
+ ...(opts.ignore.includes('weekend') ? ['weekend'] : []),
+ val,
+ ],
+ }),
+ },
+ (id, val) => {
+ let el = document.getElementById(id);
+ if (id === 'toggle-weekends' && val) {
+ document.getElementById('ignore-weekends').checked = false;
+ }
+ if (id === 'ignore-weekends' && val) {
+ document.getElementById('toggle-weekends').checked = false;
+ }
+ },
+ ],
+];
+
+for (let [chart, details, after] of UPDATES) {
+ for (let id in details) {
+ let el = document.getElementById(id);
+
+ el.onchange = (e) => {
+ let label = details[id];
+ let val;
+ if (e.currentTarget.type === 'checkbox') {
+ if (typeof label === 'string') {
+ let opposite = label.slice(0, 5) === 'opp__';
+ if (opposite) label = label.slice(5);
+ val = opposite
+ ? !e.currentTarget.checked
+ : e.currentTarget.checked;
+ } else if (typeof label === 'object') {
+ val = label[e.currentTarget.checked ? 1 : 2];
+ label = label[0];
+ } else {
+ val =
+ e.currentTarget.type === 'checkbox'
+ ? e.currentTarget.checked
+ : e.currentTarget.value;
+ }
+ } else {
+ val =
+ e.currentTarget.type === 'date'
+ ? e.currentTarget.value
+ : +e.currentTarget.value;
+ }
+
+ if (typeof label === 'function') {
+ console.log('ha', label(val, chart.options));
+ chart.update_options(label(val, chart.options));
+ } else {
+ chart.update_options({
+ [label]: val,
+ });
+ }
+ after && after(id, val, chart);
+ };
+ }
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..6831439
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,19 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import js from "@eslint/js";
+import { FlatCompat } from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+ recommendedConfig: js.configs.recommended,
+ allConfig: js.configs.all
+});
+
+export default [...compat.extends("plugin:prettier/recommended"), {
+ languageOptions: {
+ ecmaVersion: 6,
+ sourceType: "module",
+ },
+}];
\ No newline at end of file
diff --git a/index.html b/index.html
deleted file mode 100644
index 69468e8..0000000
--- a/index.html
+++ /dev/null
@@ -1,132 +0,0 @@
-
-
-
-
-
Simple Gantt
-
-
-
-
-
-
-
- Interactive Gantt Chart entirely made in SVG!
-
-
-
-
-
-
diff --git a/package.json b/package.json
index 5200b6a..e6511d8 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,10 @@
{
"name": "frappe-gantt",
- "version": "0.9.0",
+ "version": "1.0.0",
"description": "A simple, modern, interactive gantt library for the web",
"main": "src/index.js",
"type": "module",
"scripts": {
- "start": "yarn run dev",
"dev": "vite",
"build-dev": "vite build --watch",
"build": "vite build",
diff --git a/src/arrow.js b/src/arrow.js
index 258a44d..da6925c 100644
--- a/src/arrow.js
+++ b/src/arrow.js
@@ -21,64 +21,70 @@ export default class Arrow {
while (condition()) {
start_x -= 10;
}
+ start_x -= 10;
- const start_y =
- this.gantt.options.header_height +
+ let start_y =
+ this.gantt.config.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;
+ this.gantt.options.padding / 2;
- const end_x =
- this.to_task.$bar.getX() - this.gantt.options.padding / 2 - 7;
- const end_y =
- this.gantt.options.header_height +
+ let end_x = this.to_task.$bar.getX() - 13;
+ let end_y =
+ this.gantt.config.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;
+ this.gantt.options.padding / 2;
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`;
+ let curve = this.gantt.options.arrow_curve;
+ const clockwise = from_is_below_to ? 1 : 0;
+ let curve_y = from_is_below_to ? -curve : curve;
if (
- this.to_task.$bar.getX() <
+ this.to_task.$bar.getX() <=
this.from_task.$bar.getX() + this.gantt.options.padding
) {
- const down_1 = this.gantt.options.padding / 2 - curve;
+ let down_1 = this.gantt.options.padding / 2 - curve;
+ if (down_1 < 0) {
+ down_1 = 0;
+ curve = this.gantt.options.padding / 2;
+ curve_y = from_is_below_to ? -curve : 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}
+ a ${curve} ${curve} 0 0 1 ${-curve} ${curve}
H ${left}
- a ${curve} ${curve} 0 0 ${clockwise} -${curve} ${curve_y}
+ 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`;
+ } else {
+ if (end_x < start_x + curve) curve = end_x - start_x;
+
+ let offset = from_is_below_to ? end_y + curve : end_y - curve;
+
+ this.path = `
+ M ${start_x} ${start_y}
+ V ${offset}
+ a ${curve} ${curve} 0 0 ${clockwise} ${curve} ${curve}
+ L ${end_x} ${end_y}
+ m -5 -5
+ l 5 5
+ l -5 5`;
}
}
diff --git a/src/bar.js b/src/bar.js
index fc60db4..2ff5c45 100644
--- a/src/bar.js
+++ b/src/bar.js
@@ -4,7 +4,21 @@ import { $, createSVG, animateSVG } from './svg_utils';
export default class Bar {
constructor(gantt, task) {
this.set_defaults(gantt, task);
- this.prepare();
+ this.prepare_wrappers();
+ this.prepare_helpers();
+ this.refresh();
+ }
+
+ refresh() {
+ this.bar_group.innerHTML = '';
+ this.handle_group.innerHTML = '';
+ if (this.task.custom_class) {
+ this.group.classList.add(this.task.custom_class);
+ } else {
+ this.group.classList = ['bar-wrapper'];
+ }
+
+ this.prepare_values();
this.draw();
this.bind();
}
@@ -13,11 +27,24 @@ export default class Bar {
this.action_completed = false;
this.gantt = gantt;
this.task = task;
+ this.name = this.name || '';
}
- prepare() {
- this.prepare_values();
- this.prepare_helpers();
+ prepare_wrappers() {
+ this.group = createSVG('g', {
+ class:
+ 'bar-wrapper' +
+ (this.task.custom_class ? ' ' + 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_values() {
@@ -29,25 +56,8 @@ export default class Bar {
this.compute_duration();
this.corner_radius = this.gantt.options.bar_corner_radius;
this.width = this.gantt.config.column_width * this.duration;
- this.progress_width =
- this.gantt.config.column_width *
- this.duration *
- (this.task.progress / 100) || 0;
- this.group = createSVG('g', {
- class:
- 'bar-wrapper' +
- (this.task.custom_class ? ' ' + this.task.custom_class : '') +
- (this.task.important ? ' important' : ''),
- '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,
- });
+ if (this.task.progress < 0) this.task.progress = 0;
+ if (this.task.progress > 100) this.task.progress = 100;
}
prepare_helpers() {
@@ -101,12 +111,12 @@ export default class Bar {
ry: this.corner_radius,
class:
'bar' +
- (/^((?!chrome|android).)*safari/i.test(navigator.userAgent) &&
- !this.task.important
+ (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
? ' safari'
: ''),
append_to: this.bar_group,
});
+ if (this.task.color) this.$bar.style.fill = this.task.color;
animateSVG(this.$bar, 'width', 0, this.width);
if (this.invalid) {
@@ -137,7 +147,7 @@ export default class Bar {
draw_progress_bar() {
if (this.invalid) return;
-
+ this.progress_width = this.calculate_progress_width();
this.$bar_progress = createSVG('rect', {
x: this.x,
y: this.y,
@@ -148,25 +158,59 @@ export default class Bar {
class: 'bar-progress',
append_to: this.bar_group,
});
+ if (this.task.color_progress)
+ this.$bar_progress.style.fill = this.task.color;
const x =
- (date_utils.diff(this.task._start, this.gantt.gantt_start, 'hour') /
+ (date_utils.diff(
+ this.task._start,
+ this.gantt.gantt_start,
+ this.gantt.config.unit,
+ ) /
this.gantt.config.step) *
this.gantt.config.column_width;
- let $date_highlight = document.createElement('div');
- $date_highlight.id = `highlight-${this.task.id}`;
- $date_highlight.classList.add('date-highlight');
- $date_highlight.style.height = this.height * 0.8 + 'px';
- $date_highlight.style.width = this.width + 'px';
- $date_highlight.style.top =
- this.gantt.options.header_height - 25 + 'px';
- $date_highlight.style.left = x + 'px';
+ let $date_highlight = this.gantt.create_el({
+ classes: `date-range-highlight hide highlight-${this.task.id}`,
+ width: this.width,
+ left: x,
+ });
this.$date_highlight = $date_highlight;
- this.gantt.$lower_header.prepend($date_highlight);
+ this.gantt.$lower_header.prepend(this.$date_highlight);
animateSVG(this.$bar_progress, 'width', 0, this.progress_width);
}
+ calculate_progress_width() {
+ const width = this.$bar.getWidth();
+ const ignored_end = this.x + width;
+ const total_ignored_area =
+ this.gantt.config.ignored_positions.reduce((acc, val) => {
+ return acc + (val >= this.x && val < ignored_end);
+ }, 0) * this.gantt.config.column_width;
+ let progress_width =
+ ((width - total_ignored_area) * this.task.progress) / 100;
+ const progress_end = this.x + progress_width;
+ const total_ignored_progress =
+ this.gantt.config.ignored_positions.reduce((acc, val) => {
+ return acc + (val >= this.x && val < progress_end);
+ }, 0) * this.gantt.config.column_width;
+
+ progress_width += total_ignored_progress;
+
+ let ignored_regions = this.gantt.get_ignored_region(
+ this.x + progress_width,
+ );
+
+ while (ignored_regions.length) {
+ progress_width += this.gantt.config.column_width;
+ ignored_regions = this.gantt.get_ignored_region(
+ this.x + progress_width,
+ );
+ }
+ this.progress_width = progress_width;
+ return progress_width;
+ }
+
draw_label() {
let x_coord = this.x + this.$bar.getWidth() / 2;
@@ -184,6 +228,7 @@ export default class Bar {
// labels get BBox in the next tick
requestAnimationFrame(() => this.update_label_position());
}
+
draw_thumbnail() {
let x_offset = 10,
y_offset = 2;
@@ -230,39 +275,50 @@ export default class Bar {
if (this.invalid || this.gantt.options.readonly) return;
const bar = this.$bar;
- const handle_width = 8;
- if (!this.gantt.options.dates_readonly) {
- createSVG('rect', {
- x: bar.getX() + bar.getWidth() + handle_width - 4,
- 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,
- });
+ const handle_width = 3;
+ this.handles = [];
+ if (!this.gantt.options.readonly_dates) {
+ this.handles.push(
+ createSVG('rect', {
+ x: bar.getEndX() - handle_width / 2,
+ y: bar.getY() + this.height / 4,
+ width: handle_width,
+ height: this.height / 2,
+ rx: 2,
+ ry: 2,
+ class: 'handle right',
+ append_to: this.handle_group,
+ }),
+ );
- createSVG('rect', {
- x: bar.getX() - handle_width - 4,
- 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,
- });
+ this.handles.push(
+ createSVG('rect', {
+ x: bar.getX() - handle_width / 2,
+ y: bar.getY() + this.height / 4,
+ width: handle_width,
+ height: this.height / 2,
+ rx: 2,
+ ry: 2,
+ class: 'handle left',
+ append_to: this.handle_group,
+ }),
+ );
}
- if (!this.gantt.options.progress_readonly) {
+ if (!this.gantt.options.readonly_progress) {
const bar_progress = this.$bar_progress;
this.$handle_progress = createSVG('circle', {
cx: bar_progress.getEndX(),
cy: bar_progress.getY() + bar_progress.getHeight() / 2,
- r: 5,
+ r: 4.5,
class: 'handle progress',
append_to: this.handle_group,
});
+ this.handles.push(this.$handle_progress);
+ }
+
+ for (let handle of this.handles) {
+ $.on(handle, 'mouseenter', () => handle.classList.add('active'));
+ $.on(handle, 'mouseleave', () => handle.classList.remove('active'));
}
}
@@ -283,40 +339,44 @@ export default class Bar {
});
if (this.gantt.options.popup_on === 'click') {
- let opened = false;
- $.on(this.group, 'click', (e) => {
- if (!opened) {
- this.show_popup(e.offsetX || e.layerX);
- document.getElementById(
- `highlight-${task_id}`,
- ).style.display = 'block';
- } else {
- this.gantt.hide_popup();
+ $.on(this.group, 'mouseup', (e) => {
+ const posX = e.offsetX || e.layerX;
+ if (this.$handle_progress) {
+ const cx = +this.$handle_progress.getAttribute('cx');
+ if (cx > posX - 1 && cx < posX + 1) return;
+ if (this.gantt.bar_being_dragged) return;
}
- opened = !opened;
- });
- } else {
- let timeout;
- $.on(
- this.group,
- 'mouseenter',
- (e) =>
- (timeout = setTimeout(() => {
- this.show_popup(e.offsetX || e.layerX);
- document.getElementById(
- `highlight-${task_id}`,
- ).style.display = 'block';
- }, 200)),
- );
-
- $.on(this.group, 'mouseleave', () => {
- clearTimeout(timeout);
- this.gantt.popup?.hide?.();
-
- document.getElementById(`highlight-${task_id}`).style.display =
- 'none';
+ this.gantt.show_popup({
+ x: e.offsetX || e.layerX,
+ y: e.offsetY || e.layerY,
+ task: this.task,
+ target: this.$bar,
+ });
});
}
+ let timeout;
+ $.on(this.group, 'mouseenter', (e) => {
+ timeout = setTimeout(() => {
+ if (this.gantt.options.popup_on === 'hover')
+ this.gantt.show_popup({
+ x: e.offsetX || e.layerX,
+ y: e.offsetY || e.layerY,
+ task: this.task,
+ target: this.$bar,
+ });
+ this.gantt.$container
+ .querySelector(`.highlight-${task_id}`)
+ .classList.remove('hide');
+ }, 200);
+ });
+ $.on(this.group, 'mouseleave', () => {
+ clearTimeout(timeout);
+ if (this.gantt.options.popup_on === 'hover')
+ this.gantt.popup?.hide?.();
+ this.gantt.$container
+ .querySelector(`.highlight-${task_id}`)
+ .classList.add('hide');
+ });
$.on(this.group, 'click', () => {
this.gantt.trigger_event('click', [this.task]);
@@ -329,70 +389,48 @@ export default class Bar {
}
this.group.classList.remove('active');
if (this.gantt.popup)
- this.gantt.popup.parent.classList.remove('hidden');
+ this.gantt.popup.parent.classList.remove('hide');
this.gantt.trigger_event('double_click', [this.task]);
});
}
- show_popup(x) {
- 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}
Progress: ${this.task.progress}`;
- this.gantt.show_popup({
- x,
- 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((_, curr) => {
return x >= curr;
}, x);
- if (!valid_x) {
- width = null;
- return;
- }
+ if (!valid_x) return;
this.update_attr(bar, 'x', x);
+ this.x = x;
this.$date_highlight.style.left = x + 'px';
}
- if (width) {
+ if (width > 0) {
this.update_attr(bar, 'width', width);
this.$date_highlight.style.width = width + 'px';
}
+
this.update_label_position();
this.update_handle_position();
+ this.date_changed();
+ this.compute_duration();
+
if (this.gantt.options.show_expected_progress) {
- this.date_changed();
- this.compute_duration();
this.update_expected_progressbar_position();
}
+
this.update_progressbar_position();
this.update_arrow_position();
}
update_label_position_on_horizontal_scroll({ x, sx }) {
- const container = document.querySelector('.gantt-container');
+ const container =
+ this.gantt.$container.querySelector('.gantt-container');
const label = this.group.querySelector('.bar-label');
const img = this.group.querySelector('.bar-img') || '';
const img_mask = this.bar_group.querySelector('.img_mask') || '';
@@ -448,9 +486,11 @@ export default class Bar {
}
progress_changed() {
- const new_progress = this.compute_progress();
- this.task.progress = new_progress;
- this.gantt.trigger_event('progress_change', [this.task, new_progress]);
+ this.task.progress = this.compute_progress();
+ this.gantt.trigger_event('progress_change', [
+ this.task,
+ this.task.progress,
+ ]);
}
set_action_completed() {
@@ -466,17 +506,6 @@ export default class Bar {
x_in_units * this.gantt.config.step,
this.gantt.config.unit,
);
- const start_offset =
- this.gantt.gantt_start.getTimezoneOffset() -
- new_start_date.getTimezoneOffset();
-
- if (start_offset) {
- new_start_date = date_utils.add(
- new_start_date,
- start_offset,
- 'minute',
- );
- }
const width_in_units = bar.getWidth() / this.gantt.config.column_width;
const new_end_date = date_utils.add(
@@ -489,9 +518,20 @@ export default class Bar {
}
compute_progress() {
+ this.progress_width = this.$bar_progress.getWidth();
+ this.x = this.$bar_progress.getBBox().x;
+ const progress_area = this.x + this.progress_width;
const progress =
- (this.$bar_progress.getWidth() / this.$bar.getWidth()) * 100;
- return parseInt(progress, 10);
+ this.progress_width -
+ this.gantt.config.ignored_positions.reduce((acc, val) => {
+ return acc + (val >= this.x && val <= progress_area);
+ }, 0) *
+ this.gantt.config.column_width;
+ if (progress < 0) return 0;
+ const total =
+ this.$bar.getWidth() -
+ this.ignored_duration_raw * this.gantt.config.column_width;
+ return parseInt((progress / total) * 100, 10);
}
compute_expected_progress() {
@@ -507,13 +547,14 @@ export default class Bar {
}
compute_x() {
- const { step, column_width } = this.gantt.config;
+ const { column_width } = this.gantt.config;
const task_start = this.task._start;
const gantt_start = this.gantt.gantt_start;
const diff =
date_utils.diff(task_start, gantt_start, this.gantt.config.unit) /
this.gantt.config.step;
+
let x = diff * column_width;
/* Since the column width is based on 30,
@@ -521,72 +562,67 @@ export default class Bar {
and then add the days in the month, making sure the number does not exceed 29
so it is within the column */
- if (this.gantt.view_is('Month')) {
- const diffDaysBasedOn30DayMonths =
- date_utils.diff(task_start, gantt_start, 'month') * 30;
- const dayInMonth = Math.min(
- 29,
- date_utils.format(
- task_start,
- 'DD',
- this.gantt.options.language,
- ),
- );
- const diff = diffDaysBasedOn30DayMonths + dayInMonth;
+ // if (this.gantt.view_is('Month')) {
+ // const diffDaysBasedOn30DayMonths =
+ // date_utils.diff(task_start, gantt_start, 'month') * 30;
+ // const dayInMonth = Math.min(
+ // 29,
+ // date_utils.format(
+ // task_start,
+ // 'DD',
+ // this.gantt.options.language,
+ // ),
+ // );
+ // const diff = diffDaysBasedOn30DayMonths + dayInMonth;
- x = (diff * column_width) / 30;
- }
+ // x = (diff * column_width) / 30;
+ // }
this.x = x;
}
compute_y() {
this.y =
- this.gantt.options.header_height +
- this.gantt.options.padding +
+ this.gantt.config.header_height +
+ this.gantt.options.padding / 2 +
this.task._index * (this.height + this.gantt.options.padding);
}
compute_duration() {
+ let actual_duration_in_days = 0,
+ duration_in_days = 0;
+ for (
+ let d = new Date(this.task._start);
+ d < this.task._end;
+ d.setDate(d.getDate() + 1)
+ ) {
+ duration_in_days++;
+ if (
+ !this.gantt.config.ignored_dates.find(
+ (k) => k.getTime() === d.getTime(),
+ ) &&
+ (!this.gantt.config.ignored_function ||
+ !this.gantt.config.ignored_function(d))
+ ) {
+ actual_duration_in_days++;
+ }
+ }
+ this.task.actual_duration = actual_duration_in_days;
+ this.task.ignored_duration = duration_in_days - actual_duration_in_days;
+
this.duration =
- date_utils.diff(
- this.task._end,
- this.task._start,
+ date_utils.convert_scales(
+ duration_in_days + 'd',
this.gantt.config.unit,
) / this.gantt.config.step;
- }
- get_snap_position(dx) {
- let odx = dx,
- rem,
- position;
+ this.actual_duration_raw =
+ date_utils.convert_scales(
+ actual_duration_in_days + 'd',
+ this.gantt.config.unit,
+ ) / this.gantt.config.step;
- // if (this.gantt.view_is('Week')) {
- // rem = dx % (this.gantt.config.column_width / 7);
- // position =
- // odx -
- // rem +
- // (rem < this.gantt.config.column_width / 14
- // ? 0
- // : this.gantt.config.column_width / 7);
- // } else if (this.gantt.view_is('Month')) {
- // rem = dx % (this.gantt.config.column_width / 30);
- // position =
- // odx -
- // rem +
- // (rem < this.gantt.config.column_width / 60
- // ? 0
- // : this.gantt.config.column_width / 30);
- // } else {
- rem = dx % this.gantt.config.column_width;
- position =
- odx -
- rem +
- (rem < this.gantt.config.column_width / 2
- ? 0
- : this.gantt.config.column_width);
- // }
- return position;
+ this.ignored_duration_raw = this.duration - this.actual_duration_raw;
}
update_attr(element, attr, value) {
@@ -604,7 +640,7 @@ export default class Bar {
this.$expected_bar_progress.setAttribute(
'width',
this.gantt.config.column_width *
- this.duration *
+ this.actual_duration_raw *
(this.expected_progress / 100) || 0,
);
}
@@ -612,9 +648,10 @@ export default class Bar {
update_progressbar_position() {
if (this.invalid || this.gantt.options.readonly) return;
this.$bar_progress.setAttribute('x', this.$bar.getX());
+
this.$bar_progress.setAttribute(
'width',
- this.$bar.getWidth() * (this.task.progress / 100),
+ this.calculate_progress_width(),
);
}
@@ -631,17 +668,11 @@ export default class Bar {
if (labelWidth > barWidth) {
label.classList.add('big');
if (img) {
- img.setAttribute('x', bar.getX() + bar.getWidth() + padding);
- img_mask.setAttribute(
- 'x',
- bar.getX() + bar.getWidth() + padding,
- );
- label.setAttribute(
- 'x',
- bar.getX() + bar.getWidth() + x_offset_label_img,
- );
+ img.setAttribute('x', bar.getEndX() + padding);
+ img_mask.setAttribute('x', bar.getEndX() + padding);
+ label.setAttribute('x', bar.getEndX() + x_offset_label_img);
} else {
- label.setAttribute('x', bar.getX() + bar.getWidth() + padding);
+ label.setAttribute('x', bar.getEndX() + padding);
}
} else {
label.classList.remove('big');
@@ -666,10 +697,10 @@ export default class Bar {
const bar = this.$bar;
this.handle_group
.querySelector('.handle.left')
- .setAttribute('x', bar.getX() - 12);
+ .setAttribute('x', bar.getX());
this.handle_group
.querySelector('.handle.right')
- .setAttribute('x', bar.getEndX() + 4);
+ .setAttribute('x', bar.getEndX());
const handle = this.group.querySelector('.handle.progress');
handle && handle.setAttribute('cx', this.$bar_progress.getEndX());
}
@@ -681,11 +712,3 @@ export default class Bar {
}
}
}
-
-function isFunction(functionToCheck) {
- let getType = {};
- return (
- functionToCheck &&
- getType.toString.call(functionToCheck) === '[object Function]'
- );
-}
diff --git a/src/dark.css b/src/dark.css
deleted file mode 100644
index c5bc9af..0000000
--- a/src/dark.css
+++ /dev/null
@@ -1,99 +0,0 @@
-:root {
- --bar-color-dark: #616161;
- --bar-stroke-dark: #c6ccd2;
- --border-color-dark: #616161;
- --light-bg-dark: #3e3e3e;
- --light-border-color-dark: #3e3e3e;
- --text-muted-dark: #eee;
- --text-light-dark: #ececec;
- --text-color-dark: #f7f7f7;
- --blue-dark: #8a8aff;
-}
-
-.dark>.gantt-container .gantt {
- & .grid-row {
- fill: #252525;
- }
-
- /* & .grid-row:nth-child(even) {
- fill: var(--light-bg-dark);
- } */
-
- & .row-line {
- stroke: var(--light-border-color-dark);
- }
-
- & .tick {
- stroke: var(--border-color-dark);
- }
-
- & .holiday-highlight {
- fill: var(--light-bg-dark);
- }
-
- & .arrow {
- stroke: var(--text-muted-dark);
- }
-
- & .bar {
- fill: var(--bar-color-dark);
- stroke: none;
- }
-
- & .bar-progress {
- fill: var(--blue-dark);
- }
-
- & .bar-invalid {
- fill: transparent;
- stroke: var(--bar-stroke-dark);
-
- &~.bar-label {
- fill: var(--text-light-dark);
- }
- }
-
- & .bar-label.big {
- fill: var(--text-light-dark);
- }
-
- & .bar-wrapper {
- &:hover {
- .bar {
- fill: lighten(var(--bar-color-dark, 5));
- }
-
- & .bar-progress {
- fill: lighten(var(--blue-dark, 5));
- }
- }
-
- &.active {
- .bar {
- fill: lighten(var(--bar-color-dark, 5));
- }
-
- & .bar-progress {
- fill: lighten(var(--blue-dark, 5));
- }
- }
- }
-}
-
-.dark>.gantt-container {
- & .grid-header {
- background-color: #252525;
- }
-
- & .popup-wrapper {
- background-color: #333;
-
- & .title {
- border-color: lighten(var(--blue-dark, 5));
- }
-
- & .pointer {
- border-top-color: #333;
- }
- }
-}
\ No newline at end of file
diff --git a/src/date_utils.js b/src/date_utils.js
index f0526bf..f9112e9 100644
--- a/src/date_utils.js
+++ b/src/date_utils.js
@@ -78,7 +78,7 @@ export default {
return date_string + (with_time ? ' ' + time_string : '');
},
- format(date, format_string = 'YYYY-MM-DD HH:mm:ss.SSS', lang = 'en') {
+ format(date, date_format = 'YYYY-MM-DD HH:mm:ss.SSS', lang = 'en') {
const dateTimeFormat = new Intl.DateTimeFormat(lang, {
month: 'long',
});
@@ -103,7 +103,7 @@ export default {
MMM: dateTimeFormatShort.format(date),
};
- let str = format_string;
+ let str = date_format;
const formatted_values = [];
Object.keys(format_map)
@@ -125,7 +125,10 @@ export default {
diff(date_a, date_b, scale = 'day') {
let milliseconds, seconds, hours, minutes, days, months, years;
- milliseconds = date_a - date_b;
+ milliseconds =
+ date_a -
+ date_b +
+ (date_b.getTimezoneOffset() - date_a.getTimezoneOffset()) * 60000;
seconds = milliseconds / 1000;
minutes = seconds / 60;
hours = minutes / 60;
diff --git a/src/defaults.js b/src/defaults.js
index e138521..e63e8ef 100644
--- a/src/defaults.js
+++ b/src/defaults.js
@@ -1,13 +1,26 @@
import date_utils from './date_utils';
+function getDecade(d) {
+ const year = d.getFullYear();
+ return year - (year % 10) + '';
+}
+
+function formatWeek(d, ld, lang) {
+ let endOfWeek = date_utils.add(d, 6, 'day');
+ let endFormat = endOfWeek.getMonth() !== d.getMonth() ? 'D MMM' : 'D';
+ let beginFormat = !ld || d.getMonth() !== ld.getMonth() ? 'D MMM' : 'D';
+ return `${date_utils.format(d, beginFormat, lang)} - ${date_utils.format(endOfWeek, endFormat, lang)}`;
+}
+
const DEFAULT_VIEW_MODES = [
{
name: 'Hour',
padding: '7d',
step: '1h',
+ date_format: 'YYYY-MM-DD HH:',
lower_text: 'HH',
upper_text: (d, ld, lang) =>
- d.getDate() !== ld.getDate()
+ !ld || d.getDate() !== ld.getDate()
? date_utils.format(d, 'D MMMM', lang)
: '',
upper_text_frequency: 24,
@@ -16,22 +29,22 @@ const DEFAULT_VIEW_MODES = [
name: 'Quarter Day',
padding: '7d',
step: '6h',
- format_string: 'YYYY-MM-DD HH',
+ date_format: 'YYYY-MM-DD HH:',
lower_text: 'HH',
upper_text: (d, ld, lang) =>
- d.getDate() !== ld.getDate()
+ !ld || d.getDate() !== ld.getDate()
? date_utils.format(d, 'D MMM', lang)
: '',
upper_text_frequency: 4,
},
{
name: 'Half Day',
- padding: '7d',
+ padding: '14d',
step: '12h',
- format_string: 'YYYY-MM-DD HH',
+ date_format: 'YYYY-MM-DD HH:',
lower_text: 'HH',
upper_text: (d, ld, lang) =>
- d.getDate() !== ld.getDate()
+ !ld || d.getDate() !== ld.getDate()
? d.getMonth() !== d.getMonth()
? date_utils.format(d, 'D MMM', lang)
: date_utils.format(d, 'D', lang)
@@ -40,13 +53,15 @@ const DEFAULT_VIEW_MODES = [
},
{
name: 'Day',
- padding: '14d',
- format_string: 'YYYY-MM-DD',
+ padding: '7d',
+ date_format: 'YYYY-MM-DD',
step: '1d',
lower_text: (d, ld, lang) =>
- d.getDate() !== ld.getDate() ? date_utils.format(d, 'D', lang) : '',
+ !ld || d.getDate() !== ld.getDate()
+ ? date_utils.format(d, 'D', lang)
+ : '',
upper_text: (d, ld, lang) =>
- d.getMonth() !== ld.getMonth()
+ !ld || d.getMonth() !== ld.getMonth()
? date_utils.format(d, 'MMMM', lang)
: '',
thick_line: (d) => d.getDay() === 1,
@@ -55,13 +70,11 @@ const DEFAULT_VIEW_MODES = [
name: 'Week',
padding: '1m',
step: '7d',
+ date_format: 'YYYY-MM-DD',
column_width: 140,
- lower_text: (d, ld, lang) =>
- d.getMonth() !== ld.getMonth()
- ? date_utils.format(d, 'D MMM', lang)
- : date_utils.format(d, 'D', lang),
+ lower_text: formatWeek,
upper_text: (d, ld, lang) =>
- d.getMonth() !== ld.getMonth()
+ !ld || d.getMonth() !== ld.getMonth()
? date_utils.format(d, 'MMMM', lang)
: '',
thick_line: (d) => d.getDate() >= 1 && d.getDate() <= 7,
@@ -72,51 +85,76 @@ const DEFAULT_VIEW_MODES = [
padding: '2m',
step: '1m',
column_width: 120,
- format_string: 'YYYY-MM',
+ date_format: 'YYYY-MM',
lower_text: 'MMMM',
upper_text: (d, ld, lang) =>
!ld || d.getFullYear() !== ld.getFullYear()
? date_utils.format(d, 'YYYY', lang)
: '',
thick_line: (d) => d.getMonth() % 3 === 0,
- default_snap: '7d',
+ snap_at: '7d',
},
{
name: 'Year',
padding: '2y',
step: '1y',
column_width: 120,
- format_string: 'YYYY',
- upper_text: 'YYYY',
- default_snap: '30d',
+ date_format: 'YYYY',
+ upper_text: (d, ld, lang) =>
+ !ld || getDecade(d) !== getDecade(ld) ? getDecade(d) : '',
+ lower_text: 'YYYY',
+ snap_at: '30d',
},
];
const DEFAULT_OPTIONS = {
- header_height: 65,
- column_width: 30,
- view_modes: DEFAULT_VIEW_MODES,
- bar_height: 30,
- bar_corner_radius: 3,
arrow_curve: 5,
- padding: 18,
- view_mode: 'Day',
- date_format: 'YYYY-MM-DD',
- move_dependencies: true,
- show_expected_progress: false,
- popup: null,
- popup_on: 'hover',
+ auto_move_label: false,
+ bar_corner_radius: 3,
+ bar_height: 30,
+ container_height: 'auto',
+ column_width: null,
+ date_format: 'YYYY-MM-DD HH:mm',
+ upper_header_height: 45,
+ lower_header_height: 30,
+ snap_at: null,
+ infinite_padding: true,
+ holidays: { 'var(--g-weekend-highlight-color)': 'weekend' },
+ ignore: [],
language: 'en',
- readonly: false,
- progress_readonly: false,
- dates_readonly: false,
- highlight_weekend: true,
- scroll_to: 'start',
lines: 'both',
- auto_move_label: true,
+ move_dependencies: true,
+ padding: 18,
+ popup: (ctx) => {
+ ctx.set_title(ctx.task.name);
+ if (ctx.task.description) ctx.set_subtitle(ctx.task.description);
+ else ctx.set_subtitle('');
+
+ const start_date = date_utils.format(
+ ctx.task._start,
+ 'MMM D',
+ ctx.chart.options.language,
+ );
+ const end_date = date_utils.format(
+ date_utils.add(ctx.task._end, -1, 'second'),
+ 'MMM D',
+ ctx.chart.options.language,
+ );
+
+ ctx.set_details(
+ `${start_date} - ${end_date} (${ctx.task.actual_duration} days${ctx.task.ignored_duration ? ' + ' + ctx.task.ignored_duration + ' excluded' : ''})
Progress: ${Math.floor(ctx.task.progress * 100) / 100}%`,
+ );
+ },
+ popup_on: 'click',
+ readonly_progress: false,
+ readonly_dates: false,
+ readonly: false,
+ scroll_to: 'today',
+ show_expected_progress: false,
today_button: true,
+ view_mode: 'Day',
view_mode_select: false,
- default_snap: '1d',
+ view_modes: DEFAULT_VIEW_MODES,
};
export { DEFAULT_OPTIONS, DEFAULT_VIEW_MODES };
diff --git a/src/gantt.css b/src/gantt.css
deleted file mode 100644
index 2fa3328..0000000
--- a/src/gantt.css
+++ /dev/null
@@ -1,310 +0,0 @@
-@import './dark.css';
-
-:root {
- --bar-color: #fff;
- --bar-color-important: #94c4f4;
- --bar-stroke: #fff;
- --dark-stroke-color: #e0e0e0;
- --stroke-color: #ebeef0;
- --light-bg: #f5f5f5;
- --light-border-color: #ebeff2;
- --light-yellow: #f6e796;
- --holiday-color: #f9fafa;
- --text-muted: #7c7c7c;
- --text-grey: #98a1a9;
- --text-light: #fff;
- --text-dark: #171717;
- --progress: #ebeef0;
- --handle-color: #dcdce4;
- --handle-color-important: #94c4f4;
- --light-blue: #c4c4e9;
- --middle-blue: #62b2f9;
- --dark-blue: #2c94ec;
-}
-
-.gantt-container {
- line-height: 14.5px;
- position: relative;
- overflow: auto;
- font-size: 12px;
- height: 500px;
- width: fit-content;
-
- & .popup-wrapper {
- position: absolute;
- top: 0;
- left: 0;
- background: #171b1f;
- padding: 10px;
- border-radius: 5px;
- width: max-content;
-
- &.hidden {
- opacity: 0 !important;
- }
-
- & .title {
- margin-bottom: 5px;
- text-align: -webkit-center;
- text-align: center;
- color: var(--text-light);
- }
-
- & .subtitle {
- color: var(--text-grey);
- }
-
- & .pointer {
- position: absolute;
- height: 5px;
- margin: 0 0 0 -5px;
- border: 5px solid transparent;
- border-bottom-color: rgba(0, 0, 0, 0.8);
- }
- }
-
- & .grid-header {
- background-color: #ffffff;
- position: sticky;
- top: 0;
- left: 0;
- z-index: 10;
- }
-
- & .lower-text,
- & .upper-text {
- text-anchor: middle;
- }
-
- & .upper-header {
- height: 40px;
- }
-
- & .lower-header {
- height: 30px;
- }
-
- & .lower-text {
- font-size: 14px;
- position: absolute;
- width: fit-content;
- transform: translateX(-50%);
- color: var(--text-muted);
- }
-
- & .upper-text {
- position: absolute;
- width: fit-content;
- font-weight: 500;
- font-size: 16px;
- color: var(--text-dark);
- }
-
- & .current-upper {
- position: fixed;
- }
-
- & .side-header {
- position: fixed;
- padding: 0 10px;
- margin-right: 10px;
- background: white;
- line-height: 20px;
- font-weight: 400;
- }
-
- & .today-button,
- & .viewmode-select {
- background: #f4f5f6;
- text-align: -webkit-center;
- text-align: center;
- height: 25px;
- border-radius: 8px;
- border: none;
- color: var(--text-dark);
- padding: 4px 10px;
- border-radius: 8px;
- height: 25px;
- }
-
- & .viewmode-select {
- outline: none !important;
- padding: 4px 8px;
- margin-right: 4px;
-
- /* -webkit-appearance: none; */
- /* -moz-appearance: none; */
- text-indent: 1px;
- text-overflow: '';
- }
-
- & .date-highlight {
- background-color: var(--progress);
- border-radius: 12px;
- position: absolute;
- display: none;
- }
-
- & .current-highlight {
- position: absolute;
- background: var(--dark-blue);
- width: 1px;
- }
-
- & .current-date-highlight {
- background: var(--dark-blue);
- color: var(--text-light);
- padding: 4px 8px;
- border-radius: 200px;
- }
-}
-
-.gantt {
- user-select: none;
- -webkit-user-select: none;
- position: absolute;
-
- & .grid-background {
- fill: none;
- }
-
- & .grid-row {
- fill: #ffffff;
- }
-
- & .row-line {
- stroke: var(--light-border-color);
- }
-
- & .tick {
- stroke: var(--stroke-color);
- stroke-width: 0.4;
-
- &.thick {
- stroke: var(--dark-stroke-color);
- stroke-width: 0.7;
- }
- }
-
- & .holiday-highlight {
- fill: var(--holiday-color);
- }
-
- & .arrow {
- fill: none;
- stroke: #9fa9b1;
- stroke-width: 1;
- }
-
- & .bar-wrapper .bar {
- fill: var(--bar-color);
- stroke: var(--bar-stroke);
- stroke-width: 0;
- transition: stroke-width 0.3s ease;
- }
-
- & .bar-progress {
- fill: var(--progress);
- }
-
- & .bar-expected-progress {
- fill: var(--light-blue);
- }
-
- & .bar-invalid {
- fill: transparent;
- stroke: var(--bar-stroke);
- stroke-width: 1;
- stroke-dasharray: 5;
-
- & ~ .bar-label {
- fill: var(--text-light);
- }
- }
-
- & .bar-label {
- fill: var(--text-dark);
- dominant-baseline: central;
- font-family: Helvetica;
- font-size: 13px;
- font-weight: 400;
-
- &.big {
- fill: var(--text-dark);
- text-anchor: start;
- }
- }
-
- & .bar-wrapper.important {
- & .bar {
- fill: var(--bar-color-important);
- }
-
- & .bar-progress {
- fill: var(--dark-blue);
- }
-
- & .bar-label {
- fill: var(--text-light);
-
- &.big {
- fill: var(--text-dark);
- }
- }
-
- & .handle {
- fill: var(--handle-color-important);
- }
-
- & .handle.progress {
- fill: var(--text-light);
- }
- }
-
- & .handle {
- fill: var(--handle-color);
- cursor: ew-resize;
- opacity: 0;
- visibility: hidden;
- transition: opacity 0.3s ease;
- }
-
- & .handle.progress {
- fill: var(--text-muted);
- }
-
- & .bar-wrapper {
- cursor: pointer;
-
- &.active {
- & .handle {
- visibility: visible;
- opacity: 1;
- }
- }
-
- & .bar {
- -webkit-filter: drop-shadow(3px 3px 2px rgba(0, 0, 0, 0.7));
- filter: drop-shadow(0 0 2px rgba(17, 43, 66, 0.16));
- border-radius: 3px;
- }
-
- & .bar.safari {
- outline: 1px solid black;
- }
-
- &:hover {
- .bar {
- transition: transform 0.3s ease;
- }
-
- .date-highlight {
- display: block;
- }
- }
- }
-
- & .hide {
- display: none;
- }
-}
diff --git a/src/index.js b/src/index.js
index ce52dd0..93524b6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,7 +7,7 @@ import Popup from './popup';
import { DEFAULT_OPTIONS, DEFAULT_VIEW_MODES } from './defaults';
-import './gantt.css';
+import './styles/gantt.css';
export default class Gantt {
constructor(wrapper, tasks, options) {
@@ -23,7 +23,13 @@ export default class Gantt {
// CSS Selector is passed
if (typeof element === 'string') {
- element = document.querySelector(element);
+ let el = document.querySelector(element);
+ if (!el) {
+ throw new ReferenceError(
+ `CSS selector "${element}" could not be found in DOM`,
+ );
+ }
+ element = el;
}
// get the SVGElement
@@ -52,33 +58,68 @@ export default class Gantt {
}
// wrapper element
- this.$container = document.createElement('div');
- this.$container.classList.add('gantt-container');
+ this.$container = this.create_el({
+ classes: 'gantt-container',
+ append_to: this.$svg.parentElement,
+ });
- 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);
+ this.$popup_wrapper = this.create_el({
+ classes: 'popup-wrapper',
+ append_to: this.$container,
+ });
}
setup_options(options) {
+ this.original_options = options;
this.options = { ...DEFAULT_OPTIONS, ...options };
- const custom_mode = this.options.custom_view_modes
- ? this.options.custom_view_modes.find(
- (m) => m.name === this.config.view_mode.name,
- )
- : null;
- if (custom_mode) this.options = { ...this.options, custom_mode };
+ const CSS_VARIABLES = {
+ 'grid-height': 'container_height',
+ 'bar-height': 'bar_height',
+ 'lower-header-height': 'lower_header_height',
+ 'upper-header-height': 'upper_header_height',
+ };
+ for (let name in CSS_VARIABLES) {
+ let setting = this.options[CSS_VARIABLES[name]];
+ if (setting !== 'auto')
+ this.$container.style.setProperty(
+ '--gv-' + name,
+ setting + 'px',
+ );
+ }
- this.config = {};
+ this.config = {
+ ignored_dates: [],
+ ignored_positions: [],
+ extend_by_units: 10,
+ };
+
+ if (typeof this.options.ignore !== 'function') {
+ if (typeof this.options.ignore === 'string')
+ this.options.ignore = [this.options.ignord];
+ for (let option of this.options.ignore) {
+ if (typeof option === 'function') {
+ this.config.ignored_function = option;
+ continue;
+ }
+ if (typeof option === 'string') {
+ if (option === 'weekend')
+ this.config.ignored_function = (d) =>
+ d.getDay() == 6 || d.getDay() == 0;
+ else this.config.ignored_dates.push(new Date(option + ' '));
+ }
+ }
+ } else {
+ this.config.ignored_function = this.options.ignore;
+ }
+ }
+
+ update_options(options) {
+ this.setup_options({ ...this.original_options, ...options });
+ this.change_view_mode(undefined, true);
}
setup_tasks(tasks) {
- // prepare tasks
this.tasks = tasks
.map((task, i) => {
if (!task.start) {
@@ -176,14 +217,35 @@ export default class Gantt {
this.change_view_mode();
}
- change_view_mode(mode = this.options.view_mode) {
+ update_task(id, new_details) {
+ let task = this.tasks.find((t) => t.id === id);
+ let bar = this.bars[task._index];
+ Object.assign(task, new_details);
+ bar.refresh();
+ }
+
+ change_view_mode(mode = this.options.view_mode, maintain_pos = false) {
if (typeof mode === 'string') {
mode = this.options.view_modes.find((d) => d.name === mode);
}
+ let old_date, old_scroll_op;
+ if (maintain_pos) {
+ old_date = this.current_date;
+ old_scroll_op = this.options.scroll_to;
+ this.options.scroll_to = null;
+ }
+ this.options.view_mode = mode.name;
this.config.view_mode = mode;
this.update_view_scale(mode);
- this.setup_dates();
+ this.setup_dates(maintain_pos);
this.render();
+ if (maintain_pos) {
+ this.options.scroll_to = old_scroll_op;
+ this.$container.scrollLeft =
+ (date_utils.diff(old_date, this.gantt_start, this.config.unit) /
+ this.config.step) *
+ this.config.column_width;
+ }
this.trigger_event('view_change', [mode]);
}
@@ -192,15 +254,23 @@ export default class Gantt {
this.config.step = duration;
this.config.unit = scale;
this.config.column_width =
- mode.column_width || this.options.column_width;
+ this.options.column_width || mode.column_width || 45;
+ this.$container.style.setProperty(
+ '--gv-column-width',
+ this.config.column_width + 'px',
+ );
+ this.config.header_height =
+ this.options.lower_header_height +
+ this.options.upper_header_height +
+ 10;
}
- setup_dates() {
- this.setup_gantt_dates();
+ setup_dates(refresh = false) {
+ this.setup_gantt_dates(refresh);
this.setup_date_values();
}
- setup_gantt_dates() {
+ setup_gantt_dates(refresh) {
let gantt_start, gantt_end;
if (!this.tasks.length) {
gantt_start = new Date();
@@ -216,41 +286,47 @@ export default class Gantt {
}
}
- gantt_start = date_utils.start_of(gantt_start, 'day');
- gantt_end = date_utils.start_of(gantt_end, 'day');
+ gantt_start = date_utils.start_of(gantt_start, this.config.unit);
+ gantt_end = date_utils.start_of(gantt_end, this.config.unit);
- // handle single value for padding
- if (typeof this.config.view_mode.padding === 'string')
- this.config.view_mode.padding = [
- this.config.view_mode.padding,
- this.config.view_mode.padding,
- ];
+ if (!refresh) {
+ if (!this.options.infinite_padding) {
+ if (typeof this.config.view_mode.padding === 'string')
+ this.config.view_mode.padding = [
+ this.config.view_mode.padding,
+ this.config.view_mode.padding,
+ ];
- let [padding_start, padding_end] = this.config.view_mode.padding.map(
- date_utils.parse_duration,
- );
- gantt_start = date_utils.add(
- gantt_start,
- -padding_start.duration,
- padding_start.scale,
- );
-
- let format_string =
- this.config.view_mode.format_string || 'YYYY-MM-DD HH';
-
- this.gantt_start = date_utils.parse(
- date_utils.format(
- gantt_start,
- format_string,
- this.options.language,
- ),
- );
+ let [padding_start, padding_end] =
+ this.config.view_mode.padding.map(
+ date_utils.parse_duration,
+ );
+ this.gantt_start = date_utils.add(
+ gantt_start,
+ -padding_start.duration,
+ padding_start.scale,
+ );
+ this.gantt_end = date_utils.add(
+ gantt_end,
+ padding_end.duration,
+ padding_end.scale,
+ );
+ } else {
+ this.gantt_start = date_utils.add(
+ gantt_start,
+ -this.config.extend_by_units * 3,
+ this.config.unit,
+ );
+ this.gantt_end = date_utils.add(
+ gantt_end,
+ this.config.extend_by_units * 3,
+ this.config.unit,
+ );
+ }
+ }
+ this.config.date_format =
+ this.config.view_mode.date_format || this.options.date_format;
this.gantt_start.setHours(0, 0, 0, 0);
- this.gantt_end = date_utils.add(
- gantt_end,
- padding_end.duration,
- padding_end.scale,
- );
}
setup_date_values() {
@@ -268,8 +344,8 @@ export default class Gantt {
}
bind_events() {
- if (this.options.readonly) return;
this.bind_grid_click();
+ this.bind_holiday_labels();
this.bind_bar_events();
}
@@ -278,18 +354,17 @@ export default class Gantt {
this.setup_layers();
this.make_grid();
this.make_dates();
- this.make_bars();
this.make_grid_extras();
+ this.make_bars();
this.make_arrows();
this.map_arrows_on_bars();
- this.set_width();
+ this.set_dimensions();
this.set_scroll_position(this.options.scroll_to);
- this.update_button_position();
}
setup_layers() {
this.layers = {};
- const layers = ['grid', 'arrow', 'progress', 'bar', 'details'];
+ const layers = ['grid', 'arrow', 'progress', 'bar'];
// make group layers
for (let layer of layers) {
this.layers[layer] = createSVG('g', {
@@ -297,12 +372,23 @@ export default class Gantt {
append_to: this.$svg,
});
}
+ this.$extras = this.create_el({
+ classes: 'extras',
+ append_to: this.$container,
+ });
+ this.$adjust = this.create_el({
+ classes: 'adjust hide',
+ append_to: this.$extras,
+ type: 'button',
+ });
+ this.$adjust.innerHTML = '←';
}
make_grid() {
this.make_grid_background();
this.make_grid_rows();
this.make_grid_header();
+ this.make_side_header();
}
make_grid_extras() {
@@ -312,11 +398,17 @@ export default class Gantt {
make_grid_background() {
const grid_width = this.dates.length * this.config.column_width;
- const grid_height =
- this.options.header_height +
- this.options.padding +
- (this.options.bar_height + this.options.padding) *
- this.tasks.length;
+ const grid_height = Math.max(
+ this.config.header_height +
+ this.options.padding +
+ (this.options.bar_height + this.options.padding) *
+ this.tasks.length -
+ 10,
+ this.options.container_height !== 'auto'
+ ? this.options.container_height
+ : 0,
+ );
+
createSVG('rect', {
x: 0,
y: 0,
@@ -327,9 +419,12 @@ export default class Gantt {
});
$.attr(this.$svg, {
- height: grid_height + this.options.padding + 100,
+ height: grid_height,
width: '100%',
});
+ this.grid_height = grid_height;
+ if (this.options.container_height === 'auto')
+ this.$container.style.height = grid_height + 'px';
}
make_grid_rows() {
@@ -338,52 +433,43 @@ export default class Gantt {
const row_width = this.dates.length * this.config.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 _ of this.tasks) {
+ let y = this.config.header_height;
+ for (
+ let y = this.config.header_height;
+ y < this.grid_height;
+ y += row_height
+ ) {
createSVG('rect', {
x: 0,
- y: row_y,
+ y,
width: row_width,
height: row_height,
class: 'grid-row',
append_to: rows_layer,
});
- // FIX
- if (
- this.options.lines === 'both' ||
- this.options.lines === 'horizontal'
- ) {
- }
-
- row_y += this.options.bar_height + this.options.padding;
}
}
make_grid_header() {
- let $header = document.createElement('div');
- $header.style.height = this.options.header_height + 10 + 'px';
- $header.style.width =
- this.dates.length * this.config.column_width + 'px';
- $header.classList.add('grid-header');
- this.$header = $header;
- this.$container.appendChild($header);
+ this.$header = this.create_el({
+ width: this.dates.length * this.config.column_width,
+ classes: 'grid-header',
+ append_to: this.$container,
+ });
- let $upper_header = document.createElement('div');
- $upper_header.classList.add('upper-header');
- this.$upper_header = $upper_header;
- this.$header.appendChild($upper_header);
-
- let $lower_header = document.createElement('div');
- $lower_header.classList.add('lower-header');
- this.$lower_header = $lower_header;
- this.$header.appendChild($lower_header);
-
- this.make_side_header();
+ this.$upper_header = this.create_el({
+ classes: 'upper-header',
+ append_to: this.$header,
+ });
+ this.$lower_header = this.create_el({
+ classes: 'lower-header',
+ append_to: this.$header,
+ });
}
make_side_header() {
- let $side_header = document.createElement('div');
- $side_header.classList.add('side-header');
+ this.$side_header = this.create_el({ classes: 'side-header' });
+ this.$upper_header.prepend(this.$side_header);
// Create view mode change select
if (this.options.view_mode_select) {
@@ -400,16 +486,18 @@ export default class Gantt {
const $option = document.createElement('option');
$option.value = mode.name;
$option.textContent = mode.name;
+ if (mode.name === this.config.view_mode.name)
+ $option.selected = true;
$select.appendChild($option);
}
$select.addEventListener(
'change',
function () {
- this.change_view_mode($select.value);
+ this.change_view_mode($select.value, true);
}.bind(this),
);
- $side_header.appendChild($select);
+ this.$side_header.appendChild($select);
}
// Create today button
@@ -417,78 +505,33 @@ export default class Gantt {
let $today_button = document.createElement('button');
$today_button.classList.add('today-button');
$today_button.textContent = 'Today';
- $today_button.onclick = this.scroll_today.bind(this);
- $side_header.appendChild($today_button);
+ $today_button.onclick = this.scroll_current.bind(this);
+ this.$side_header.prepend($today_button);
this.$today_button = $today_button;
}
-
- this.$header.appendChild($side_header);
- this.$side_header = $side_header;
-
- window.addEventListener(
- 'scroll',
- this.update_button_position.bind(this),
- );
- window.addEventListener(
- 'resize',
- this.update_button_position.bind(this),
- );
- }
-
- update_button_position() {
- const containerRect = this.$container.getBoundingClientRect();
- const buttonRect = this.$side_header.getBoundingClientRect();
- const { left, y } = this.$header.getBoundingClientRect();
-
- // Check if the button is scrolled out of the container vertically
-
- if (
- buttonRect.top < containerRect.top ||
- buttonRect.bottom > containerRect.bottom
- ) {
- this.$side_header.style.position = 'absolute';
- this.$side_header.style.top = `${containerRect.scrollTop + buttonRect.top}px`;
- } else {
- this.$side_header.style.position = 'fixed';
- this.$side_header.style.top = y + 10 + 'px';
- }
- const width = Math.min(
- this.$header.clientWidth,
- this.$container.clientWidth,
- );
-
- this.$side_header.style.left =
- left +
- this.$container.scrollLeft +
- width -
- this.$side_header.clientWidth +
- 'px';
-
- // Update the left value on page resize
- if (this.$today_button) {
- this.$today_button.style.left = `${containerRect.left + 20}px`;
- }
}
make_grid_ticks() {
if (this.options.lines === 'none') return;
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;
+ let tick_y = this.config.header_height;
+ let tick_height = this.grid_height - this.config.header_height;
let $lines_layer = createSVG('g', {
class: 'lines_layer',
append_to: this.layers.grid,
});
- let row_y = this.options.header_height + this.options.padding / 2;
+ let row_y = this.config.header_height;
const row_width = this.dates.length * this.config.column_width;
const row_height = this.options.bar_height + this.options.padding;
if (this.options.lines !== 'vertical') {
- for (let _ of this.tasks) {
+ for (
+ let y = this.config.header_height;
+ y < this.grid_height;
+ y += row_height
+ ) {
createSVG('line', {
x1: 0,
y1: row_y + row_height,
@@ -533,32 +576,89 @@ export default class Gantt {
}
}
- highlightWeekends() {
- // FIX
- if (!this.view_is('Day') && !this.view_is('Half Day')) return;
- for (
- let d = new Date(this.gantt_start);
- d <= this.gantt_end;
- d.setDate(d.getDate() + 1)
- ) {
- if (d.getDay() === 0 || d.getDay() === 6) {
- const x =
- (date_utils.diff(d, this.gantt_start, this.config.unit) /
- this.config.step) *
- this.config.column_width;
- const height =
- (this.options.bar_height + this.options.padding) *
- this.tasks.length;
- createSVG('rect', {
- x,
- y: this.options.header_height + this.options.padding / 2,
- width:
- (this.view_is('Day') ? 1 : 2) *
- this.config.column_width,
- height,
- class: 'holiday-highlight',
- append_to: this.layers.grid,
- });
+ highlight_holidays() {
+ let labels = {};
+ if (!this.options.holidays) return;
+
+ for (let color in this.options.holidays) {
+ let check_highlight = this.options.holidays[color];
+ if (check_highlight === 'weekend')
+ check_highlight = (d) => d.getDay() === 0 || d.getDay() === 6;
+ let extra_func;
+
+ if (typeof check_highlight === 'object') {
+ let f = check_highlight.find((k) => typeof k === 'function');
+ if (f) {
+ extra_func = f;
+ }
+ if (this.options.holidays.name) {
+ let dateObj = new Date(check_highlight.date + ' ');
+ check_highlight = (d) => dateObj.getTime() === d.getTime();
+ labels[dateObj] = check_highlight.name;
+ } else {
+ check_highlight = (d) =>
+ this.options.holidays[color]
+ .filter((k) => typeof k !== 'function')
+ .map((k) => {
+ if (k.name) {
+ let dateObj = new Date(k.date + ' ');
+ labels[dateObj] = k.name;
+ return dateObj.getTime();
+ }
+ return new Date(k + ' ').getTime();
+ })
+ .includes(d.getTime());
+ }
+ }
+ for (
+ let d = new Date(this.gantt_start);
+ d <= this.gantt_end;
+ d.setDate(d.getDate() + 1)
+ ) {
+ if (
+ this.config.ignored_dates.find(
+ (k) => k.getTime() == d.getTime(),
+ ) ||
+ (this.config.ignored_function &&
+ this.config.ignored_function(d))
+ )
+ continue;
+ if (check_highlight(d) || (extra_func && extra_func(d))) {
+ const x =
+ (date_utils.diff(
+ d,
+ this.gantt_start,
+ this.config.unit,
+ ) /
+ this.config.step) *
+ this.config.column_width;
+ const height = this.grid_height - this.config.header_height;
+ const d_formatted = date_utils
+ .format(d, 'YYYY-MM-DD', this.options.language)
+ .replace(' ', '_');
+
+ if (labels[d]) {
+ let label = this.create_el({
+ classes: 'holiday-label ' + 'label_' + d_formatted,
+ append_to: this.$extras,
+ });
+ label.textContent = labels[d];
+ }
+ createSVG('rect', {
+ x: Math.round(x),
+ y: this.config.header_height,
+ width:
+ this.config.column_width /
+ date_utils.convert_scales(
+ this.config.view_mode.step,
+ 'day',
+ ),
+ height,
+ class: 'holiday-highlight ' + d_formatted,
+ style: `fill: ${color};`,
+ append_to: this.layers.grid,
+ });
+ }
}
}
}
@@ -568,99 +668,133 @@ export default class Gantt {
*
* @returns Object containing the x-axis distance and date of the current date, or null if the current date is out of the gantt range.
*/
- computeGridHighlightDimensions(view_mode) {
- const today = new Date();
- if (today < this.gantt_start || today > this.gantt_end) return null;
- let diff_in_units = date_utils.diff(
- today,
+ highlight_current() {
+ const res = this.get_closest_date();
+ if (!res) return;
+
+ const [_, el] = res;
+ el.classList.add('current-date-highlight');
+
+ const diff_in_units = date_utils.diff(
+ new Date(),
this.gantt_start,
this.config.unit,
);
- return {
- x: (diff_in_units / this.config.step) * this.config.column_width,
- date: date_utils.format(
- today,
- this.config.view_mode.format_string,
- this.options.language,
- ),
- };
- }
- make_grid_highlights() {
- if (this.options.highlight_weekend) this.highlightWeekends();
+ const left =
+ (diff_in_units / this.config.step) * this.config.column_width;
- const highlightDimensions = this.computeGridHighlightDimensions(
- this.config.view_mode,
- );
- if (!highlightDimensions) return;
- const { x: left, date } = highlightDimensions;
-
- const top = this.options.header_height + this.options.padding / 2;
- const height =
- (this.options.bar_height + this.options.padding) *
- this.tasks.length;
this.$current_highlight = this.create_el({
- top,
+ top: this.config.header_height,
left,
- height,
+ height: this.grid_height - this.config.header_height,
classes: 'current-highlight',
append_to: this.$container,
});
- let $today = document.getElementById(date.replaceAll(' ', '_'));
- if ($today) {
- $today.classList.add('current-date-highlight');
- $today.style.top = +$today.style.top.slice(0, -2) - 4 + 'px';
- }
+ this.$current_ball_highlight = this.create_el({
+ top: this.config.header_height - 6,
+ left: left - 2.5,
+ width: 6,
+ height: 6,
+ classes: 'current-ball-highlight',
+ append_to: this.$header,
+ });
}
- create_el({ left, top, width, height, id, classes, append_to }) {
- let $el = document.createElement('div');
- $el.classList.add(classes);
+ make_grid_highlights() {
+ this.highlight_holidays();
+ this.config.ignored_positions = [];
+
+ const height =
+ (this.options.bar_height + this.options.padding) *
+ this.tasks.length;
+ this.layers.grid.innerHTML += `
+
+ `;
+
+ for (
+ let d = new Date(this.gantt_start);
+ d <= this.gantt_end;
+ d.setDate(d.getDate() + 1)
+ ) {
+ if (
+ !this.config.ignored_dates.find(
+ (k) => k.getTime() == d.getTime(),
+ ) &&
+ (!this.config.ignored_function ||
+ !this.config.ignored_function(d))
+ )
+ continue;
+ let diff =
+ date_utils.convert_scales(
+ date_utils.diff(d, this.gantt_start) + 'd',
+ this.config.unit,
+ ) / this.config.step;
+
+ this.config.ignored_positions.push(diff * this.config.column_width);
+ createSVG('rect', {
+ x: diff * this.config.column_width,
+ y: this.config.header_height,
+ width: this.config.column_width,
+ height: height,
+ class: 'ignored-bar',
+ style: 'fill: url(#diagonalHatch);',
+ append_to: this.$svg,
+ });
+ }
+
+ const highlightDimensions = this.highlight_current(
+ this.config.view_mode,
+ );
+
+ if (!highlightDimensions) return;
+ }
+
+ create_el({ left, top, width, height, id, classes, append_to, type }) {
+ let $el = document.createElement(type || 'div');
+ for (let cls of classes.split(' ')) $el.classList.add(cls);
$el.style.top = top + 'px';
$el.style.left = left + 'px';
if (id) $el.id = id;
- if (width) $el.style.width = height + 'px';
+ if (width) $el.style.width = width + 'px';
if (height) $el.style.height = height + 'px';
- append_to.appendChild($el);
+ if (append_to) append_to.appendChild($el);
return $el;
}
make_dates() {
- this.upper_texts_x = {};
this.get_dates_to_draw().forEach((date, i) => {
- let $lower_text = this.create_el({
- left: date.lower_x,
- top: date.lower_y,
- id: date.formatted_date,
- classes: 'lower-text',
- append_to: this.$lower_header,
- });
-
- $lower_text.innerText = date.lower_text;
- $lower_text.style.left =
- +$lower_text.style.left.slice(0, -2) + 'px';
+ if (date.lower_text) {
+ let $lower_text = this.create_el({
+ left: date.x,
+ top: date.lower_y,
+ classes: 'lower-text date_' + sanitize(date.formatted_date),
+ append_to: this.$lower_header,
+ });
+ $lower_text.innerText = date.lower_text;
+ }
if (date.upper_text) {
- this.upper_texts_x[date.upper_text] = date.upper_x;
- let $upper_text = document.createElement('div');
- $upper_text.classList.add('upper-text');
- $upper_text.style.left = date.upper_x + 'px';
- $upper_text.style.top = date.upper_y + 'px';
+ let $upper_text = this.create_el({
+ left: date.x,
+ top: date.upper_y,
+ classes: 'upper-text',
+ append_to: this.$upper_header,
+ });
$upper_text.innerText = date.upper_text;
- this.$upper_header.appendChild($upper_text);
-
- // remove out-of-bound dates
- if (date.upper_x > this.layers.grid.getBBox().width) {
- $upper_text.remove();
- }
}
});
+ this.upperTexts = Array.from(
+ this.$container.querySelectorAll('.upper-text'),
+ );
}
get_dates_to_draw() {
let last_date_info = null;
const dates = this.dates.map((date, i) => {
- console.log('starting', date, last_date_info);
const d = this.get_date_info(date, last_date_info, i);
last_date_info = d;
return d;
@@ -673,42 +807,50 @@ export default class Gantt {
let column_width = this.config.column_width;
- const base_pos = {
- x: last_date_info
- ? last_date_info.base_pos_x + last_date_info.column_width
- : 20,
- lower_y: this.options.header_height - 20,
- upper_y: this.options.header_height - 50,
- };
+ const x = last_date_info
+ ? last_date_info.x + last_date_info.column_width
+ : 0;
let upper_text = this.config.view_mode.upper_text;
let lower_text = this.config.view_mode.lower_text;
- if (!upper_text) upper_text = () => '';
- if (!lower_text) lower_text = () => '';
+
+ if (!upper_text) {
+ this.config.view_mode.upper_text = () => '';
+ } else if (typeof upper_text === 'string') {
+ this.config.view_mode.upper_text = (date) =>
+ date_utils.format(date, upper_text, this.options.language);
+ }
+
+ if (!lower_text) {
+ this.config.view_mode.lower_text = () => '';
+ } else if (typeof lower_text === 'string') {
+ this.config.view_mode.lower_text = (date) =>
+ date_utils.format(date, lower_text, this.options.language);
+ }
return {
date,
- formatted_date: date_utils
- .format(date, this.config.view_mode.format_string)
- .replaceAll(' ', '_'),
+ formatted_date: sanitize(
+ date_utils.format(
+ date,
+ this.config.date_format,
+ this.options.language,
+ ),
+ ),
column_width: this.config.column_width,
- base_pos_x: base_pos.x,
- upper_text:
- typeof upper_text === 'string'
- ? date_utils.format(date, upper_text, this.options.language)
- : upper_text(date, last_date, this.options.language),
- lower_text:
- typeof lower_text === 'string'
- ? date_utils.format(date, lower_text, this.options.language)
- : lower_text(date, last_date, this.options.language),
- upper_x:
- base_pos.x +
- (column_width * this.config.view_mode.upper_text_frequency ||
- 1) /
- 2,
- upper_y: base_pos.upper_y,
- lower_x: base_pos.x + column_width / 2 - 20,
- lower_y: base_pos.lower_y,
+ x,
+ upper_text: this.config.view_mode.upper_text(
+ date,
+ last_date,
+ this.options.language,
+ ),
+ lower_text: this.config.view_mode.lower_text(
+ date,
+ last_date,
+ this.options.language,
+ ),
+ upper_y: 15,
+ lower_y: this.options.upper_header_height + 5,
};
}
@@ -752,8 +894,8 @@ export default class Gantt {
}
}
- set_width() {
- const cur_width = this.$svg.getBoundingClientRect().width;
+ set_dimensions() {
+ const { width: cur_width } = this.$svg.getBoundingClientRect();
const actual_width = this.$svg.querySelector('.grid .grid-row')
? this.$svg.querySelector('.grid .grid-row').getAttribute('width')
: 0;
@@ -763,15 +905,23 @@ export default class Gantt {
}
set_scroll_position(date) {
+ if (this.options.infinite_padding && (!date || date === 'start')) {
+ let [min_start, ..._] = this.get_start_end_positions();
+ this.$container.scrollLeft = min_start;
+ return;
+ }
if (!date || date === 'start') {
date = this.gantt_start;
+ } else if (date === 'end') {
+ date = this.gantt_end;
} else if (date === 'today') {
- return this.scroll_today();
+ return this.scroll_current();
} else if (typeof date === 'string') {
date = date_utils.parse(date);
}
- const parent_element = this.$svg.parentElement;
- if (!parent_element) return;
+
+ // Weird bug where infinite padding results in one day offset in scroll
+ // Related to header-body displacement
const units_since_first_task = date_utils.diff(
date,
this.gantt_start,
@@ -779,20 +929,148 @@ export default class Gantt {
);
const scroll_pos =
(units_since_first_task / this.config.step) *
- this.config.column_width -
this.config.column_width;
- parent_element.scrollTo({ left: 400, behavior: 'smooth' });
+
+ this.$container.scrollTo({
+ left: scroll_pos - this.config.column_width / 6,
+ behavior: 'smooth',
+ });
+
+ // Calculate current scroll position's upper text
+ if (this.$current) {
+ this.$current.classList.remove('current-upper');
+ }
+
+ this.current_date = date_utils.add(
+ this.gantt_start,
+ this.$container.scrollLeft / this.config.column_width,
+ this.config.unit,
+ );
+
+ let current_upper = this.config.view_mode.upper_text(
+ this.current_date,
+ null,
+ this.options.language,
+ );
+ let $el = this.upperTexts.find(
+ (el) => el.textContent === current_upper,
+ );
+
+ // Recalculate
+ this.current_date = date_utils.add(
+ this.gantt_start,
+ (this.$container.scrollLeft + $el.clientWidth) /
+ this.config.column_width,
+ this.config.unit,
+ );
+ current_upper = this.config.view_mode.upper_text(
+ this.current_date,
+ null,
+ this.options.language,
+ );
+ $el = this.upperTexts.find((el) => el.textContent === current_upper);
+ $el.classList.add('current-upper');
+ this.$current = $el;
}
- scroll_today() {
- this.set_scroll_position(new Date());
+ scroll_current() {
+ let res = this.get_closest_date();
+ if (res) this.set_scroll_position(res[0]);
+ }
+
+ get_closest_date() {
+ let now = new Date();
+ if (now < this.gantt_start || now > this.gantt_end) return null;
+
+ let current = new Date(),
+ el = this.$container.querySelector(
+ '.date_' +
+ sanitize(
+ date_utils.format(
+ current,
+ this.config.date_format,
+ this.options.language,
+ ),
+ ),
+ );
+
+ // safety check to prevent infinite loop
+ let c = 0;
+ while (!el && c < this.config.step) {
+ current = date_utils.add(current, -1, this.config.unit);
+ el = this.$container.querySelector(
+ '.date_' +
+ sanitize(
+ date_utils.format(
+ current,
+ this.config.date_format,
+ this.options.language,
+ ),
+ ),
+ );
+ c++;
+ }
+ return [
+ new Date(
+ date_utils.format(
+ current,
+ this.config.date_format,
+ this.options.language,
+ ) + ' ',
+ ),
+ el,
+ ];
}
bind_grid_click() {
- $.on(this.$svg, 'click', '.grid-row, .grid-header', () => {
- this.unselect_all();
- this.hide_popup();
+ $.on(
+ this.$svg,
+ 'click',
+ '.grid-row, .grid-header, .ignored-bar',
+ () => {
+ this.unselect_all();
+ this.hide_popup();
+ },
+ );
+ }
+
+ bind_holiday_labels() {
+ const $highlights =
+ this.$container.querySelectorAll('.holiday-highlight');
+ for (let h of $highlights) {
+ const label = this.$container.querySelector(
+ '.label_' + h.classList[1],
+ );
+ if (!label) continue;
+ let timeout;
+ h.onmouseenter = (e) => {
+ timeout = setTimeout(() => {
+ label.classList.add('show');
+ label.style.left = (e.offsetX || e.layerX) + 'px';
+ label.style.top = (e.offsetY || e.layerY) + 'px';
+ }, 300);
+ };
+
+ h.onmouseleave = (e) => {
+ clearTimeout(timeout);
+ label.classList.remove('show');
+ };
+ }
+ }
+
+ get_start_end_positions() {
+ if (!this.bars.length) return [0, 0, 0];
+ let { x, width } = this.bars[0].group.getBBox();
+ let min_start = x;
+ let max_start = x;
+ let max_end = x + width;
+ Array.prototype.forEach.call(this.bars, function ({ group }, i) {
+ let { x, width } = group.getBBox();
+ if (x < min_start) min_start = x;
+ if (x > max_start) max_start = x;
+ if (x + width > max_end) max_end = x + width;
});
+ return [min_start, max_start, max_end];
}
bind_bar_events() {
@@ -806,28 +1084,35 @@ export default class Gantt {
let bars = []; // instanceof Bar
this.bar_being_dragged = null;
- function action_in_progress() {
- return is_dragging || is_resizing_left || is_resizing_right;
- }
+ const action_in_progress = () =>
+ is_dragging || is_resizing_left || is_resizing_right;
this.$svg.onclick = (e) => {
if (e.target.classList.contains('grid-row')) this.unselect_all();
};
+ let pos = 0;
+ $.on(this.$svg, 'mousemove', '.bar-wrapper, .handle', (e) => {
+ if (
+ this.bar_being_dragged === false &&
+ Math.abs((e.offsetX || e.layerX) - pos) > 10
+ )
+ this.bar_being_dragged = true;
+ });
+
$.on(this.$svg, 'mousedown', '.bar-wrapper, .handle', (e, element) => {
const bar_wrapper = $.closest('.bar-wrapper', element);
- bars.forEach((bar) => bar.group.classList.remove('active'));
if (element.classList.contains('left')) {
is_resizing_left = true;
+ element.classList.add('visible');
} else if (element.classList.contains('right')) {
is_resizing_right = true;
+ element.classList.add('visible');
} else if (element.classList.contains('bar-wrapper')) {
is_dragging = true;
}
- bar_wrapper.classList.add('active');
-
- if (this.popup) this.popup.parent.classList.add('hidden');
+ if (this.popup) this.popup.hide();
x_on_start = e.offsetX || e.layerX;
y_on_start = e.offsetY || e.layerY;
@@ -844,7 +1129,8 @@ export default class Gantt {
}
bars = ids.map((id) => this.get_bar(id));
- this.bar_being_dragged = parent_bar_id;
+ this.bar_being_dragged = false;
+ pos = x_on_start;
bars.forEach((bar) => {
const $bar = bar.$bar;
@@ -855,58 +1141,129 @@ export default class Gantt {
});
});
+ if (this.options.infinite_padding) {
+ let extended = false;
+ $.on(this.$container, 'mousewheel', (e) => {
+ let trigger = this.$container.scrollWidth / 2;
+ if (!extended && e.currentTarget.scrollLeft <= trigger) {
+ let old_scroll_left = e.currentTarget.scrollLeft;
+ extended = true;
+
+ this.gantt_start = date_utils.add(
+ this.gantt_start,
+ -this.config.extend_by_units,
+ this.config.unit,
+ );
+ this.setup_date_values();
+ this.render();
+ e.currentTarget.scrollLeft =
+ old_scroll_left +
+ this.config.column_width * this.config.extend_by_units;
+ setTimeout(() => (extended = false), 300);
+ }
+
+ if (
+ !extended &&
+ e.currentTarget.scrollWidth -
+ (e.currentTarget.scrollLeft +
+ e.currentTarget.clientWidth) <=
+ trigger
+ ) {
+ let old_scroll_left = e.currentTarget.scrollLeft;
+ extended = true;
+ this.gantt_end = date_utils.add(
+ this.gantt_end,
+ this.config.extend_by_units,
+ this.config.unit,
+ );
+ this.setup_date_values();
+ this.render();
+ e.currentTarget.scrollLeft = old_scroll_left;
+ setTimeout(() => (extended = false), 300);
+ }
+ });
+ }
+
$.on(this.$container, 'scroll', (e) => {
- let elements = document.querySelectorAll('.bar-wrapper');
let localBars = [];
- const ids = [];
+ const ids = this.bars.map(({ group }) =>
+ group.getAttribute('data-id'),
+ );
let dx;
if (x_on_scroll_start) {
dx = e.currentTarget.scrollLeft - x_on_scroll_start;
}
- const daysSinceStart =
- ((e.currentTarget.scrollLeft / this.config.column_width) *
- this.config.step) /
- 24;
- let format_str = 'D MMM';
- if (['Year', 'Month'].includes(this.config.view_mode.name))
- format_str = 'YYYY';
- else if (['Day', 'Week'].includes(this.config.view_mode.name))
- format_str = 'MMMM';
- else if (this.view_is('Half Day')) format_str = 'D';
- else if (this.view_is('Hour')) format_str = 'D MMMM';
+ // Calculate current scroll position's upper text
+ this.current_date = date_utils.add(
+ this.gantt_start,
+ (e.currentTarget.scrollLeft / this.config.column_width) *
+ this.config.step,
+ this.config.unit,
+ );
- let currentUpper = date_utils.format(
- date_utils.add(this.gantt_start, daysSinceStart, 'day'),
- format_str,
+ let current_upper = this.config.view_mode.upper_text(
+ this.current_date,
+ null,
this.options.language,
);
- const upperTexts = Array.from(
- document.querySelectorAll('.upper-text'),
+ let $el = this.upperTexts.find(
+ (el) => el.textContent === current_upper,
);
- const $el = upperTexts.find(
- (el) => el.textContent === currentUpper,
+
+ // Recalculate for smoother experience
+ this.current_date = date_utils.add(
+ this.gantt_start,
+ ((e.currentTarget.scrollLeft + $el.clientWidth) /
+ this.config.column_width) *
+ this.config.step,
+ this.config.unit,
);
- if ($el && !$el.classList.contains('current-upper')) {
- const $current = document.querySelector('.current-upper');
- if ($current) {
- $current.classList.remove('current-upper');
- $current.style.left =
- this.upper_texts_x[$current.textContent] + 'px';
- $current.style.top = this.options.header_height - 50 + 'px';
- }
+ current_upper = this.config.view_mode.upper_text(
+ this.current_date,
+ null,
+ this.options.language,
+ );
+ $el = this.upperTexts.find(
+ (el) => el.textContent === current_upper,
+ );
+
+ if ($el !== this.$current) {
+ if (this.$current)
+ this.$current.classList.remove('current-upper');
$el.classList.add('current-upper');
- let dimensions = this.$svg.getBoundingClientRect();
- $el.style.left =
- dimensions.x + this.$container.scrollLeft + 10 + 'px';
- $el.style.top =
- dimensions.y + this.options.header_height - 50 + 'px';
+ this.$current = $el;
}
- Array.prototype.forEach.call(elements, function (el, i) {
- ids.push(el.getAttribute('data-id'));
- });
+ x_on_scroll_start = e.currentTarget.scrollLeft;
+ let [min_start, max_start, max_end] =
+ this.get_start_end_positions();
+
+ if (x_on_scroll_start > max_end + 100) {
+ this.$adjust.innerHTML = '←';
+ this.$adjust.classList.remove('hide');
+ this.$adjust.onclick = () => {
+ this.$container.scrollTo({
+ left: max_start,
+ behavior: 'smooth',
+ });
+ };
+ } else if (
+ x_on_scroll_start + e.currentTarget.offsetWidth <
+ min_start - 100
+ ) {
+ this.$adjust.innerHTML = '→';
+ this.$adjust.classList.remove('hide');
+ this.$adjust.onclick = () => {
+ this.$container.scrollTo({
+ left: min_start,
+ behavior: 'smooth',
+ });
+ };
+ } else {
+ this.$adjust.classList.add('hide');
+ }
if (dx) {
localBars = ids.map((id) => this.get_bar(id));
@@ -919,8 +1276,6 @@ export default class Gantt {
});
}
}
-
- x_on_scroll_start = e.currentTarget.scrollLeft;
});
$.on(this.$svg, 'mousemove', (e) => {
@@ -929,7 +1284,7 @@ export default class Gantt {
bars.forEach((bar) => {
const $bar = bar.$bar;
- $bar.finaldx = this.get_snap_position(dx);
+ $bar.finaldx = this.get_snap_position(dx, $bar.ox);
this.hide_popup();
if (is_resizing_left) {
if (parent_bar_id === bar.task.id) {
@@ -951,17 +1306,20 @@ export default class Gantt {
} else if (
is_dragging &&
!this.options.readonly &&
- !this.options.dates_readonly
+ !this.options.readonly_dates
) {
bar.update_bar_position({ x: $bar.ox + $bar.finaldx });
}
});
});
- document.addEventListener('mouseup', (e) => {
+ document.addEventListener('mouseup', () => {
is_dragging = false;
is_resizing_left = false;
is_resizing_right = false;
+ this.$container
+ .querySelector('.visible')
+ ?.classList?.remove?.('visible');
});
$.on(this.$svg, 'mouseup', (e) => {
@@ -970,6 +1328,7 @@ export default class Gantt {
const $bar = bar.$bar;
if (!$bar.finaldx) return;
bar.date_changed();
+ bar.compute_progress();
bar.set_action_completed();
});
});
@@ -1003,9 +1362,39 @@ export default class Gantt {
$bar_progress.max_dx = $bar.getWidth() - $bar_progress.getWidth();
});
+ const range_positions = this.config.ignored_positions.map((d) => [
+ d,
+ d + this.config.column_width,
+ ]);
+
$.on(this.$svg, 'mousemove', (e) => {
if (!is_resizing) return;
- let dx = (e.offsetX || e.layerX) - x_on_start;
+ let now_x = e.offsetX || e.layerX;
+
+ let moving_right = now_x > x_on_start;
+ if (moving_right) {
+ let k = range_positions.find(
+ ([begin, end]) => now_x >= begin && now_x < end,
+ );
+ while (k) {
+ now_x = k[1];
+ k = range_positions.find(
+ ([begin, end]) => now_x >= begin && now_x < end,
+ );
+ }
+ } else {
+ let k = range_positions.find(
+ ([begin, end]) => now_x > begin && now_x <= end,
+ );
+ while (k) {
+ now_x = k[0];
+ k = range_positions.find(
+ ([begin, end]) => now_x > begin && now_x <= end,
+ );
+ }
+ }
+
+ let dx = now_x - x_on_start;
if (dx > $bar_progress.max_dx) {
dx = $bar_progress.max_dx;
}
@@ -1048,14 +1437,11 @@ export default class Gantt {
return out.filter(Boolean);
}
- get_snap_position(dx) {
- let odx = dx,
- rem,
- position;
-
+ get_snap_position(dx, ox) {
let unit_length = 1;
const default_snap =
- this.config.view_mode.default_snap || this.options.default_snap;
+ this.options.snap_at || this.config.view_mode.snap_at || '1d';
+
if (default_snap !== 'unit') {
const { duration, scale } = date_utils.parse_duration(default_snap);
unit_length =
@@ -1063,22 +1449,44 @@ export default class Gantt {
duration;
}
- rem = dx % (this.config.column_width / unit_length);
+ const rem = dx % (this.config.column_width / unit_length);
- position =
- odx -
+ let final_dx =
+ dx -
rem +
(rem < (this.config.column_width / unit_length) * 2
? 0
: this.config.column_width / unit_length);
- return position;
+ let final_pos = ox + final_dx;
+
+ const drn = final_dx > 0 ? 1 : -1;
+ let ignored_regions = this.get_ignored_region(final_pos, drn);
+ while (ignored_regions.length) {
+ final_pos += this.config.column_width * drn;
+ ignored_regions = this.get_ignored_region(final_pos, drn);
+ if (!ignored_regions.length)
+ final_pos -= this.config.column_width * drn;
+ }
+ return final_pos - ox;
+ }
+
+ get_ignored_region(pos, drn = 1) {
+ if (drn === 1) {
+ return this.config.ignored_positions.filter((val) => {
+ return pos > val && pos <= val + this.config.column_width;
+ });
+ } else {
+ return this.config.ignored_positions.filter(
+ (val) => pos >= val && pos < val + this.config.column_width,
+ );
+ }
}
unselect_all() {
- [...this.$svg.querySelectorAll('.bar-wrapper')].forEach((el) => {
- el.classList.remove('active');
- });
- if (this.popup) this.popup.parent.classList.remove('hidden');
+ if (this.popup) this.popup.parent.classList.add('hide');
+ this.$container
+ .querySelectorAll('.date-range-highlight')
+ .forEach((k) => k.classList.add('hide'));
}
view_is(modes) {
@@ -1105,12 +1513,16 @@ export default class Gantt {
});
}
- show_popup(options) {
+ show_popup(opts) {
if (this.options.popup === false) return;
if (!this.popup) {
- this.popup = new Popup(this.$popup_wrapper, this.options.popup);
+ this.popup = new Popup(
+ this.$popup_wrapper,
+ this.options.popup,
+ this,
+ );
}
- this.popup.show(options);
+ this.popup.show(opts);
}
hide_popup() {
@@ -1146,7 +1558,9 @@ export default class Gantt {
clear() {
this.$svg.innerHTML = '';
this.$header?.remove?.();
+ this.$side_header?.remove?.();
this.$current_highlight?.remove?.();
+ this.$extras?.remove?.();
this.popup?.hide?.();
}
}
@@ -1164,3 +1578,7 @@ Gantt.VIEW_MODE = {
function generate_id(task) {
return task.name + '_' + Math.random().toString(36).slice(2, 12);
}
+
+function sanitize(s) {
+ return s.replaceAll(' ', '_').replaceAll(':', '_').replaceAll('.', '_');
+}
diff --git a/src/popup.js b/src/popup.js
index 8598d9c..fe30a5b 100644
--- a/src/popup.js
+++ b/src/popup.js
@@ -1,7 +1,9 @@
export default class Popup {
- constructor(parent, custom_html) {
+ constructor(parent, popup_func, gantt) {
this.parent = parent;
- this.custom_html = custom_html;
+ this.popup_func = popup_func;
+ this.gantt = gantt;
+
this.make();
}
@@ -9,55 +11,51 @@ export default class Popup {
this.parent.innerHTML = `
-
+
+
`;
-
this.hide();
this.title = this.parent.querySelector('.title');
this.subtitle = this.parent.querySelector('.subtitle');
- this.pointer = this.parent.querySelector('.pointer');
+ this.details = this.parent.querySelector('.details');
+ this.actions = this.parent.querySelector('.actions');
}
- show(options) {
- if (!options.target_element) {
- throw new Error('target_element is required to show popup');
- }
- const target_element = options.target_element;
+ show({ x, y, task, target }) {
+ this.actions.innerHTML = '';
+ let html = this.popup_func({
+ task,
+ chart: this.gantt,
+ get_title: () => this.title,
+ set_title: (title) => (this.title.innerHTML = title),
+ get_subtitle: () => this.subtitle,
+ set_subtitle: (subtitle) => (this.subtitle.innerHTML = subtitle),
+ get_details: () => this.details,
+ set_details: (details) => (this.details.innerHTML = details),
+ add_action: (html, func) => {
+ let action = this.gantt.create_el({
+ classes: 'action-btn',
+ type: 'button',
+ append_to: this.actions,
+ });
+ if (typeof html === 'function') html = html(task);
+ action.innerHTML = html;
+ action.onclick = (e) => func(task, this.gantt, e);
+ },
+ });
+ if (html === false) return;
+ if (html) this.parent.innerHTML = html;
- 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;
- }
+ if (this.actions.innerHTML === '') this.actions.remove();
+ else this.parent.appendChild(this.actions);
- // 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();
- }
-
- this.parent.style.left = options.x - this.parent.clientWidth / 2 + 'px';
- this.parent.style.top =
- position_meta.y + position_meta.height + 10 + 'px';
-
- this.parent.classList.remove('hidden');
- this.pointer.style.left = this.parent.clientWidth / 2 + 'px';
- this.pointer.style.top = '-15px';
-
- // show
- this.parent.style.opacity = 1;
+ this.parent.style.left = x + 10 + 'px';
+ this.parent.style.top = y - 10 + 'px';
+ this.parent.classList.remove('hide');
}
hide() {
- this.parent.style.opacity = 0;
- this.parent.style.left = 0;
+ this.parent.classList.add('hide');
}
}
diff --git a/src/styles/dark.css b/src/styles/dark.css
new file mode 100644
index 0000000..7fdedc6
--- /dev/null
+++ b/src/styles/dark.css
@@ -0,0 +1,87 @@
+:root {
+ --g-bar-stroke-dark: #c6ccd2;
+ --g-border-color-dark: #616161;
+ --g-bar-color-dark: #616161;
+ --g-bg-dark: #3e3e3e;
+ --g-light-border-color-dark: #3e3e3e;
+ --g-text-muted-dark: #eee;
+ --g-text-light-dark: #ececec;
+ --g-text-color-dark: #f7f7f7;
+ --g-progress-color: #8a8aff;
+}
+
+.dark > .gantt-container .gantt {
+ & .grid-row {
+ fill: #252525;
+ }
+
+ & .row-line {
+ stroke: var(--g-light-border-color-dark);
+ }
+
+ & .tick {
+ stroke: var(--g-border-color-dark);
+ }
+
+ & .arrow {
+ stroke: var(--g-text-muted-dark);
+ }
+
+ & .bar {
+ fill: var(--g-bar-color-dark);
+ stroke: none;
+ }
+
+ & .bar-progress {
+ fill: var(--g-progress-color);
+ }
+
+ & .bar-invalid {
+ fill: transparent;
+ stroke: var(--g-bar-stroke-dark);
+
+ & ~ .bar-label {
+ fill: var(--g-text-light-dark);
+ }
+ }
+
+ & .bar-label.big {
+ fill: var(--g-text-light-dark);
+ }
+
+ & .bar-wrapper {
+ &:hover {
+ .bar {
+ fill: lighten(var(--g-bar-color-dark, 5));
+ }
+
+ & .bar-progress {
+ fill: lighten(var(--g-progress-color, 5));
+ }
+ }
+
+ &.active {
+ .bar {
+ fill: lighten(var(--g-bar-color-dark, 5));
+ }
+
+ & .bar-progress {
+ fill: lighten(var(--g-progress-color, 5));
+ }
+ }
+ }
+}
+
+.dark > .gantt-container {
+ & .grid-header {
+ background-color: #252525;
+ }
+
+ & .popup-wrapper {
+ background-color: #333;
+
+ & .title {
+ border-color: lighten(var(--g-progress-color, 5));
+ }
+ }
+}
diff --git a/src/styles/gantt.css b/src/styles/gantt.css
new file mode 100644
index 0000000..bddfc47
--- /dev/null
+++ b/src/styles/gantt.css
@@ -0,0 +1,345 @@
+@import './light.css';
+
+.gantt-container {
+ line-height: 14.5px;
+ position: relative;
+ overflow: auto;
+ font-size: 12px;
+ height: var(--gv-grid-height);
+ width: 100%;
+ border-radius: 8px;
+
+ & .popup-wrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: #fff;
+ box-shadow: 0px 10px 24px -3px rgba(0, 0, 0, 0.2);
+ padding: 10px;
+ border-radius: 5px;
+ width: max-content;
+ z-index: 1000;
+
+ & .title {
+ margin-bottom: 2px;
+ color: var(--g-text-dark);
+ font-size: 0.85rem;
+ font-weight: 650;
+ line-height: 15px;
+ }
+
+ & .subtitle {
+ color: var(--g-text-dark);
+ font-size: 0.8rem;
+ margin-bottom: 5px;
+ }
+
+ & .details {
+ color: var(--g-text-muted);
+ font-size: 0.7rem;
+ }
+
+ & .actions {
+ margin-top: 10px;
+ margin-left: 3px;
+ }
+
+ & .action-btn {
+ border: none;
+ padding: 5px 8px;
+ background-color: var(--g-popup-actions);
+ border-right: 1px solid var(--g-text-light);
+
+ &:hover {
+ background-color: brightness(97%);
+ }
+
+ &:first-child {
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+ }
+
+ &:last-child {
+ border-right: none;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ }
+ }
+ }
+
+ & .grid-header {
+ height: calc(
+ var(--gv-lower-header-height) + var(--gv-upper-header-height) + 10px
+ );
+ background-color: var(--g-header-background);
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ }
+
+ & .lower-text,
+ & .upper-text {
+ text-anchor: middle;
+ }
+
+ & .upper-header {
+ height: var(--gv-upper-header-height);
+ }
+
+ & .lower-header {
+ height: var(--gv-lower-header-height);
+ }
+
+ & .lower-text {
+ font-size: 12px;
+ position: absolute;
+ width: calc(var(--gv-column-width) * 0.8);
+ height: calc(var(--gv-lower-header-height) * 0.8);
+ margin: 0 calc(var(--gv-column-width) * 0.1);
+ align-content: center;
+ text-align: center;
+ color: var(--g-text-muted);
+ }
+
+ & .upper-text {
+ position: absolute;
+ width: fit-content;
+ font-weight: 500;
+ font-size: 16px;
+ color: var(--g-text-dark);
+ height: calc(var(--gv-lower-header-height) * 0.66);
+ }
+
+ & .current-upper {
+ position: sticky;
+ left: 0 !important;
+ padding: 0 calc(var(--gv-lower-header-height) * 0.33);
+ background: white;
+ }
+
+ & .side-header {
+ position: sticky;
+ top: 5px;
+ right: 0;
+ float: right;
+
+ z-index: 1000;
+ line-height: 20px;
+ font-weight: 400;
+ width: max-content;
+ margin-left: auto;
+ padding-right: 5px;
+ padding-top: 5px;
+ background: var(--g-header-background);
+ }
+
+ & .side-header * {
+ transition-property: background-color;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+ background-color: var(--g-actions-background);
+ text-align: -webkit-center;
+ text-align: center;
+ height: 1.75rem;
+ border-radius: 0.5rem;
+ border: none;
+ padding: 0 0.5rem;
+ color: var(--g-text-dark);
+ position: sticky;
+ margin: 5px;
+ font-size: 14px;
+ line-height: 1.15;
+ letter-spacing: 0.02em;
+ font-weight: 420;
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ &:hover {
+ filter: brightness(97.5%);
+ }
+ }
+
+ & .side-header select {
+ padding: 0;
+ padding-right: 1rem;
+ width: 85px;
+ }
+
+ & .date-range-highlight {
+ background-color: var(--g-progress-color);
+ border-radius: 12px;
+ height: calc(var(--gv-lower-header-height) - 6px);
+ top: calc(var(--gv-upper-header-height) + 5px);
+ position: absolute;
+ }
+
+ & .current-highlight {
+ position: absolute;
+ background: var(--g-today-highlight);
+ width: 1px;
+ z-index: 999;
+ }
+
+ & .current-ball-highlight {
+ position: absolute;
+ background: var(--g-today-highlight);
+ z-index: 1001;
+ border-radius: 50%;
+ }
+
+ & .current-date-highlight {
+ background: var(--g-today-highlight);
+ color: var(--g-text-light);
+ border-radius: 5px;
+ }
+
+ & .holiday-label {
+ position: absolute;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ z-index: 1000;
+ background: --g-weekend-label-color;
+ border-radius: 5px;
+ padding: 2px 5px;
+
+ &.show {
+ opacity: 100;
+ }
+ }
+
+ & .extras {
+ position: sticky;
+ left: 0px;
+
+ & .adjust {
+ position: absolute;
+ left: 8px;
+ top: calc(var(--gv-grid-height) - 60px);
+ background-color: rgba(0, 0, 0, 0.7);
+ color: white;
+ border: none;
+ padding: 8px;
+ border-radius: 3px;
+ }
+ }
+
+ .hide {
+ display: none;
+ }
+}
+
+.gantt {
+ user-select: none;
+ -webkit-user-select: none;
+ position: absolute;
+
+ & .grid-background {
+ fill: none;
+ }
+
+ & .grid-row {
+ fill: var(--g-row-color);
+ }
+
+ & .row-line {
+ stroke: var(--g-border-color);
+ }
+
+ & .tick {
+ stroke: var(--g-tick-color);
+ stroke-width: 0.4;
+
+ &.thick {
+ stroke: var(--g-tick-color-thick);
+ stroke-width: 0.7;
+ }
+ }
+
+ & .arrow {
+ fill: none;
+ stroke: var(--g-arrow-color);
+ stroke-width: 1.5;
+ }
+
+ & .bar-wrapper .bar {
+ fill: var(--g-bar-color);
+ stroke: var(--g-bar-border);
+ stroke-width: 0;
+ transition: stroke-width 0.3s ease;
+ }
+
+ & .bar-progress {
+ fill: var(--g-progress-color);
+ }
+
+ & .bar-expected-progress {
+ fill: var(--g-expected-progress);
+ }
+
+ & .bar-invalid {
+ fill: transparent;
+ stroke: var(--g-bar-border);
+ stroke-width: 1;
+ stroke-dasharray: 5;
+
+ & ~ .bar-label {
+ fill: var(--g-text-light);
+ }
+ }
+
+ & .bar-label {
+ fill: var(--g-text-dark);
+ dominant-baseline: central;
+ font-family: Helvetica;
+ font-size: 13px;
+ font-weight: 400;
+
+ &.big {
+ fill: var(--g-text-dark);
+ text-anchor: start;
+ }
+ }
+
+ & .handle {
+ fill: var(--g-handle-color);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ &.active,
+ &.visible {
+ cursor: ew-resize;
+ opacity: 1;
+ }
+ }
+
+ & .handle.progress {
+ fill: var(--g-text-muted);
+ }
+
+ & .bar-wrapper {
+ cursor: pointer;
+
+ & .bar {
+ -webkit-filter: drop-shadow(1px 1px 2px rgba(15, 15, 15, 0.2));
+ filter: drop-shadow(1px 1px 2px rgba(15, 15, 15, 0.2));
+ border-radius: 3px;
+ }
+
+ & .bar.safari {
+ outline: 1px solid black;
+ }
+
+ &:hover {
+ .bar {
+ transition: transform 0.3s ease;
+ }
+
+ .date-range-highlight {
+ display: block;
+ }
+ }
+ }
+}
diff --git a/src/styles/light.css b/src/styles/light.css
new file mode 100644
index 0000000..a17c74c
--- /dev/null
+++ b/src/styles/light.css
@@ -0,0 +1,21 @@
+:root {
+ --g-arrow-color: #d7b15b;
+ --g-bar-color: #fff;
+ --g-bar-border: #fff;
+ --g-tick-color-thick: #e0e0e0;
+ --g-tick-color: #ebeef0;
+ --g-actions-background: #f3f3f3;
+ --g-border-color: #ebeff2;
+ --g-text-muted: #7c7c7c;
+ --g-text-light: #fff;
+ --g-text-dark: #171717;
+ --g-progress-color: #f3f3f3;
+ --g-handle-color: #37352f;
+ --g-weekend-label-color: #dcdce4;
+ --g-expected-progress: #c4c4e9;
+ --g-header-background: #fff;
+ --g-row-color: #fdfdfd;
+ --g-today-highlight: #37352f;
+ --g-popup-actions: #ebeff2;
+ --g-weekend-highlight-color: #f7f7f7;
+}