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.**
-

- - - -

+![Hero Image](.github/hero-image.png) -### 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) +
+
+
+ + + + Frappe Technologies + + +
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; +}