├── .DS_Store ├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── examples ├── from-readme.html ├── index.html ├── vue.edit.html ├── vue.html └── vue.max-rows.html ├── gantt-elastic.gif ├── gantt-elastic.jpg ├── grab-scroll.gif ├── jest.config.js ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── GanttElastic.tsx ├── GanttElasticContext.ts ├── GanttElasticEvents.ts ├── components │ ├── Chart │ │ ├── Calendar │ │ │ ├── Calendar.tsx │ │ │ ├── CalendarRow.tsx │ │ │ └── RowText.tsx │ │ ├── Chart.tsx │ │ ├── DaysHighlight.tsx │ │ ├── DependencyLines.tsx │ │ ├── Grid.tsx │ │ ├── ProgressBar.tsx │ │ ├── Row │ │ │ ├── Milestone.tsx │ │ │ ├── Project.tsx │ │ │ └── Task.tsx │ │ └── Text.tsx │ ├── Expander.tsx │ ├── MainView.tsx │ ├── TaskList │ │ ├── ItemColumn.tsx │ │ ├── TaskList.tsx │ │ ├── TaskListHeader.tsx │ │ └── TaskListItem.tsx │ ├── interfaces.d.ts │ └── utils │ │ ├── charts.ts │ │ ├── columns.ts │ │ ├── options.ts │ │ ├── style.ts │ │ ├── tasks.ts │ │ └── times.ts ├── demo.tsx ├── style.css └── types.d.ts ├── tsconfig.json ├── webpack.config.js ├── webpack.dev.js └── yarn.lock /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/.DS_Store -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": "commonjs", 7 | "useBuiltIns": "usage", 8 | "corejs": 3 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ], 14 | "plugins": [ 15 | "@babel/plugin-transform-runtime", 16 | // "@babel/plugin-proposal-class-properties", 17 | // "@babel/plugin-syntax-dynamic-import", 18 | "@babel/plugin-proposal-optional-chaining", 19 | "@babel/plugin-proposal-nullish-coalescing-operator" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /scripts 2 | /config 3 | /tests 4 | /src/serviceWorker.ts 5 | /webpack.config.js 6 | /webpack.dev.js 7 | /jest.config.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaVersion": 2018, 6 | "allowImportExportEverywhere": true, 7 | "project": "./tsconfig.json" 8 | }, 9 | "env": { 10 | "node": true, 11 | "es6": true, 12 | "mocha": true, 13 | "jest": true, 14 | "jasmine": true, 15 | "browser": true, 16 | "commonjs": true 17 | }, 18 | "extends": [ 19 | "eslint:recommended", 20 | "plugin:@typescript-eslint/eslint-recommended", 21 | "plugin:@typescript-eslint/recommended", 22 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 23 | "plugin:react/recommended" 24 | ], 25 | "settings": { 26 | "react": { 27 | "version": "detect" 28 | } 29 | }, 30 | "plugins": ["react", "react-hooks", "@typescript-eslint"], 31 | "globals": { 32 | "__static": true 33 | }, 34 | "rules": { 35 | "indent": 0, 36 | "linebreak-style": ["error", "unix"], 37 | "quotes": 0, 38 | // "quotes": ["error", "double"], 39 | "semi": ["error", "always"], 40 | "no-console": 0, 41 | "no-debugger": 0, 42 | "no-process-env": "error", 43 | "no-alert": "error", 44 | "react/prop-types": 0, //防止在React组件定义中丢失props验证 45 | // "react/no-unescaped-entities": 0, 46 | "react-hooks/rules-of-hooks": "error", // 检查 Hook 的规则 47 | "react-hooks/exhaustive-deps": "warn" // 检查 effect 的依赖 48 | // "@typescript-eslint/explicit-function-return-type": [ 49 | // "warn", 50 | // { 51 | // "allowExpressions": true, 52 | // "allowTypedFunctionExpresxsions": true 53 | // } 54 | // ], 55 | // "@typescript-eslint/no-empty-function": 0, 56 | // "@typescript-eslint/unbound-method": 0 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": [".prettierrc", ".babelrc", ".eslintrc"], 5 | "options": { 6 | "parser": "json" 7 | } 8 | } 9 | ], 10 | "singleQuote": false 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 neuronet.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Gantt-elastic(Alpha) - Javascript Gantt Chart(editable, responsive)

2 |

Javascript Gantt Chart for React, jquery, vanilla js and other frameworks

3 | 4 |
5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | Twitter 14 |
15 | 16 |

17 | Keywords: [ gantt, javascript gantt, gantt chart,js gantt,react gantt,project manager,gantt project manager,responsive gantt ] 18 |

19 | 20 |

Gantt-elastic demo here

21 | 22 | ![preview img](https://github.com/neuronetio/gantt-elastic/raw/master/gantt-elastic.jpg) 23 | ![preview gif](https://github.com/neuronetio/gantt-elastic/raw/master/gantt-elastic.gif) 24 | ![preview gif](https://github.com/neuronetio/gantt-elastic/raw/master/grab-scroll.gif) 25 | 26 | ## Gantt-elastic 27 | 28 | is a vue component but it could be used in other frameworks or even with jQuery (vue is kind of elastic and lightweight framework). 29 | 30 | [WIKI](https://github.com/neuronetio/gantt-elastic/wiki) 31 | 32 | [Vue Example](https://github.com/neuronetio/vue-gantt-elastic) 33 | 34 | ### Installation 35 | 36 | `npm install --save react-gantt-elastic` or download zip from github / clone repo 37 | 38 | ### Usage 39 | 40 | ```tsx 41 | import dayjs from "dayjs"; 42 | import React from "react"; 43 | import ReactDOM from "react-dom"; 44 | import GanttElastic from "./GanttElastic"; 45 | import "./style.css"; 46 | 47 | function getDate(hours: number): number { 48 | return ( 49 | dayjs() 50 | .startOf("day") 51 | .valueOf() + 52 | hours * 60 * 60 * 1000 53 | ); 54 | } 55 | 56 | const tasks = [ 57 | { 58 | id: 1, 59 | label: "Make some noise", 60 | user: 61 | 'John Doe', 62 | start: getDate(-24 * 5), 63 | duration: 5 * 24 * 60 * 60 * 1000, 64 | progress: 85, 65 | type: "project" 66 | }, 67 | { 68 | id: 2, 69 | label: "With great power comes great responsibility", 70 | user: 71 | 'Peter Parker', 72 | parentId: 1, 73 | start: getDate(-24 * 4), 74 | duration: 4 * 24 * 60 * 60 * 1000, 75 | progress: 50, 76 | type: "milestone", 77 | style: { 78 | base: { 79 | fill: "#1EBC61", 80 | stroke: "#0EAC51" 81 | } 82 | /*'tree-row-bar': { 83 | fill: '#1EBC61', 84 | stroke: '#0EAC51' 85 | }, 86 | 'tree-row-bar-polygon': { 87 | stroke: '#0EAC51' 88 | }*/ 89 | } 90 | }, 91 | { 92 | id: 3, 93 | label: "Courage is being scared to death, but saddling up anyway.", 94 | user: 95 | 'John Wayne', 96 | parentId: 2, 97 | start: getDate(-24 * 3), 98 | duration: 2 * 24 * 60 * 60 * 1000, 99 | progress: 100, 100 | type: "task" 101 | }, 102 | { 103 | id: 4, 104 | label: "Put that toy AWAY!", 105 | user: 106 | 'Clark Kent', 107 | start: getDate(-24 * 2), 108 | duration: 2 * 24 * 60 * 60 * 1000, 109 | progress: 50, 110 | type: "task", 111 | dependentOn: [3] 112 | }, 113 | { 114 | id: 5, 115 | label: 116 | "One billion, gajillion, fafillion... shabadylu...mil...shabady......uh, Yen.", 117 | user: 118 | 'Austin Powers', 119 | parentId: 4, 120 | start: getDate(0), 121 | duration: 2 * 24 * 60 * 60 * 1000, 122 | progress: 10, 123 | type: "milestone", 124 | style: { 125 | base: { 126 | fill: "#0287D0", 127 | stroke: "#0077C0" 128 | } 129 | } 130 | }, 131 | { 132 | id: 6, 133 | label: "Butch Mario and the Luigi Kid", 134 | user: 135 | 'Mario Bros', 136 | parentId: 5, 137 | start: getDate(24), 138 | duration: 1 * 24 * 60 * 60 * 1000, 139 | progress: 50, 140 | type: "task", 141 | style: { 142 | base: { 143 | fill: "#8E44AD", 144 | stroke: "#7E349D" 145 | } 146 | } 147 | }, 148 | { 149 | id: 7, 150 | label: "Devon, the old man wanted me, it was his dying request", 151 | user: 152 | 'Knight Rider', 153 | parentId: 2, 154 | dependentOn: [6], 155 | start: getDate(24 * 2), 156 | duration: 4 * 60 * 60 * 1000, 157 | progress: 20, 158 | type: "task" 159 | }, 160 | { 161 | id: 8, 162 | label: "Hey, Baby! Anybody ever tell you I have beautiful eyes?", 163 | user: 164 | 'Johhny Bravo', 165 | parentId: 7, 166 | dependentOn: [7], 167 | start: getDate(24 * 3), 168 | duration: 1 * 24 * 60 * 60 * 1000, 169 | progress: 0, 170 | type: "task" 171 | }, 172 | { 173 | id: 9, 174 | label: 175 | "This better be important, woman. You are interrupting my very delicate calculations.", 176 | user: 177 | 'Dexter\'s Laboratory', 178 | parentId: 8, 179 | dependentOn: [8, 7], 180 | start: getDate(24 * 4), 181 | duration: 4 * 60 * 60 * 1000, 182 | progress: 20, 183 | type: "task", 184 | style: { 185 | base: { 186 | fill: "#8E44AD", 187 | stroke: "#7E349D" 188 | } 189 | } 190 | }, 191 | { 192 | id: 10, 193 | label: "current task", 194 | user: ( 195 | 200 | Johnattan Owens 201 | 202 | ), 203 | start: getDate(24 * 5), 204 | duration: 24 * 60 * 60 * 1000, 205 | progress: 0, 206 | type: "task" 207 | } 208 | ]; 209 | 210 | const options = { 211 | title: { 212 | label: "Your project title as html (link or whatever...)", 213 | html: false 214 | }, 215 | times: { 216 | timeZoom: 10, 217 | firstTime: dayjs("2020/03/10").valueOf() 218 | }, 219 | row: { height: 16 }, 220 | taskList: { 221 | columns: [ 222 | { 223 | id: 1, 224 | label: "ID", 225 | value: "id", 226 | width: 40 227 | }, 228 | { 229 | id: 2, 230 | label: "Description", 231 | value: "label", 232 | width: 200, 233 | expander: true 234 | }, 235 | { 236 | id: 3, 237 | label: "Assigned to", 238 | value: "user", 239 | width: 130, 240 | html: true 241 | }, 242 | { 243 | id: 4, 244 | label: "Start", 245 | value: task => dayjs(task.start).format("YYYY-MM-DD"), 246 | width: 78 247 | }, 248 | { 249 | id: 5, 250 | label: "Type", 251 | value: "type", 252 | width: 68 253 | }, 254 | { 255 | id: 6, 256 | label: "%", 257 | value: "progress", 258 | width: 35, 259 | style: { 260 | "task-list-header-label": { 261 | textAlign: "center", 262 | width: "100%" 263 | }, 264 | "task-list-item-value-container": { 265 | textAlign: "center" 266 | } 267 | } 268 | } 269 | ] 270 | } 271 | // locale: { 272 | // name: "pl", // name String 273 | // weekdays: "Poniedziałek_Wtorek_Środa_Czwartek_Piątek_Sobota_Niedziela".split( 274 | // "_" 275 | // ), // weekdays Array 276 | // weekdaysShort: "Pon_Wto_Śro_Czw_Pią_Sob_Nie".split("_"), // OPTIONAL, short weekdays Array, use first three letters if not provided 277 | // weekdaysMin: "Pn_Wt_Śr_Cz_Pt_So_Ni".split("_"), // OPTIONAL, min weekdays Array, use first two letters if not provided 278 | // months: "Styczeń_Luty_Marzec_Kwiecień_Maj_Czerwiec_Lipiec_Sierpień_Wrzesień_Październik_Listopad_Grudzień".split( 279 | // "_" 280 | // ), // months Array 281 | // monthsShort: "Sty_Lut_Mar_Kwi_Maj_Cze_Lip_Sie_Wrz_Paź_Lis_Gru".split("_"), // OPTIONAL, short months Array, use first three letters if not provided 282 | // ordinal: n => `${n}`, // ordinal Function (number) => return number + output 283 | // relativeTime: { 284 | // // relative time format strings, keep %s %d as the same 285 | // future: "za %s", // e.g. in 2 hours, %s been replaced with 2hours 286 | // past: "%s temu", 287 | // s: "kilka sekund", 288 | // m: "minutę", 289 | // mm: "%d minut", 290 | // h: "godzinę", 291 | // hh: "%d godzin", // e.g. 2 hours, %d been replaced with 2 292 | // d: "dzień", 293 | // dd: "%d dni", 294 | // M: "miesiąc", 295 | // MM: "%d miesięcy", 296 | // y: "rok", 297 | // yy: "%d lat" 298 | // } 299 | // } 300 | }; 301 | 302 | ReactDOM.render( 303 | , 310 | document.getElementById("root") 311 | ); 312 | ``` 313 | 314 | ### Licensce 315 | 316 | MIT 317 | -------------------------------------------------------------------------------- /examples/from-readme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GanttElastic editor demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 | 250 | 251 | 252 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Gangtt-elastic demo 7 | 8 | 9 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 303 | 304 | 305 | -------------------------------------------------------------------------------- /examples/vue.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GanttElastic demo 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | 295 | 296 | 297 | -------------------------------------------------------------------------------- /examples/vue.max-rows.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GanttElastic editor demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | 25 | modify task
26 | 27 | 28 | 29 |
30 | 31 | 32 |
33 | 40 |
41 | 42 |
43 | 44 | 229 | 230 | 231 | -------------------------------------------------------------------------------- /gantt-elastic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/gantt-elastic.gif -------------------------------------------------------------------------------- /gantt-elastic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/gantt-elastic.jpg -------------------------------------------------------------------------------- /grab-scroll.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/grab-scroll.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | const { defaults } = require("jest-config"); 3 | 4 | module.exports = { 5 | ...defaults, 6 | rootDir: "tests", 7 | verbose: true 8 | // moduleFileExtensions: [...defaults.moduleFileExtensions] 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gantt-elastic", 3 | "version": "1.0.1", 4 | "description": "Gantt chart. Elastic javascript gantt chart. React gantt. Project manager responsive gantt. jquery gantt.", 5 | "main": "src/GanttElastic.tsx", 6 | "dependencies": { 7 | "dayjs": "^1.8.20", 8 | "events": "^3.1.0", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "resize-observer-polyfill": "^1.5.1", 12 | "seamless-immutable": "^7.1.4", 13 | "ts-invariant": "^0.4.4" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.8.4", 17 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", 18 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 19 | "@babel/plugin-transform-runtime": "^7.8.3", 20 | "@babel/preset-env": "^7.8.7", 21 | "@babel/preset-react": "^7.8.3", 22 | "@babel/preset-typescript": "^7.8.3", 23 | "@babel/runtime": "^7.8.4", 24 | "@types/lodash": "^4.14.149", 25 | "@types/react": "^16.9.22", 26 | "@types/react-dom": "^16.9.5", 27 | "@types/seamless-immutable": "^7.1.11", 28 | "@typescript-eslint/eslint-plugin": "^2.20.0", 29 | "@typescript-eslint/parser": "^2.21.0", 30 | "awesome-typescript-loader": "^5.2.1", 31 | "babel-eslint": "^10.0.3", 32 | "babel-jest": "^25.1.0", 33 | "babel-loader": "^8.0.6", 34 | "babel-plugin-lodash": "^3.3.4", 35 | "css-loader": "^3.4.2", 36 | "eslint": "^6.8.0", 37 | "eslint-plugin-react": "^7.18.3", 38 | "eslint-plugin-react-hooks": "^2.5.0", 39 | "html-webpack-plugin": "4.0.0-beta.11", 40 | "jest": "^25.1.0", 41 | "lodash-webpack-plugin": "^0.11.5", 42 | "mini-css-extract-plugin": "^0.9.0", 43 | "react-dev-utils": "^10.2.0", 44 | "react-test-renderer": "^16.13.0", 45 | "style-loader": "^1.1.3", 46 | "typescript": "^3.8.2", 47 | "webpack": "^4.41.6", 48 | "webpack-cli": "^3.3.11", 49 | "webpack-dev-server": "^3.10.3" 50 | }, 51 | "scripts": { 52 | "test": "jest", 53 | "xtest": "npx cypress run --browser chrome", 54 | "build": "webpack", 55 | "dev": "webpack-dev-server --config webpack.dev.js --watch" 56 | }, 57 | "repository": { 58 | "type": "git", 59 | "url": "git+https://github.com/bignumlock/react-gantt-elastic.git" 60 | }, 61 | "keywords": [ 62 | "gantt", 63 | "gantt js", 64 | "gantt chart", 65 | "gantt elastic", 66 | "javascript gantt", 67 | "javascript gantt chart", 68 | "jQuery gantt", 69 | "react gantt", 70 | "js gantt", 71 | "gantt-chart", 72 | "responsive", 73 | "react", 74 | "react-gantt", 75 | "javascript gantt", 76 | "project gantt", 77 | "project manger", 78 | "responsive gantt", 79 | "gantt component", 80 | "javascript gantt", 81 | "project", 82 | "task" 83 | ], 84 | "author": "ich ", 85 | "license": "MIT", 86 | "bugs": { 87 | "url": "https://github.com/bignumlock/react-gantt-elastic/issues" 88 | }, 89 | "homepage": "https://github.com/bignumlock/react-gantt-elastic", 90 | "postcss": { 91 | "plugins": { 92 | "autoprefixer": {} 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ich0x00/react-gantt-elastic/02afcf40823e7fc2ec453e209aca0eb467be90a2/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/GanttElasticContext.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-empty-function */ 3 | import React from "react"; 4 | import Immutable, { ImmutableObject } from "seamless-immutable"; 5 | import { 6 | CalendarOption, 7 | GanttElasticRefs, 8 | Options, 9 | ScrollOption, 10 | Task, 11 | TaskListOption, 12 | TimeOption 13 | } from "./components/interfaces"; 14 | import { defaultOptions, defaultState } from "./components/utils/options"; 15 | import { DynamicStyle } from "./types"; 16 | 17 | export default React.createContext<{ 18 | dispatch?: React.Dispatch<{ type: string; payload: any }>; 19 | isTaskVisible?: (task: Task) => boolean; 20 | getTask?: (taskId: string | number) => Task; 21 | ctx?: CanvasRenderingContext2D | null; 22 | refs: GanttElasticRefs; 23 | style: DynamicStyle; 24 | allTasks: Task[]; 25 | visibleTasks: Task[]; 26 | // calendar: ImmutableObject; 27 | options: ImmutableObject; 28 | scroll: ImmutableObject; 29 | times: ImmutableObject; 30 | taskList: ImmutableObject; 31 | calendar: ImmutableObject; 32 | chartWidth: number; 33 | clientHeight: number; 34 | clientWidth: number; 35 | outerHeight: number; 36 | rowsHeight: number; 37 | scrollBarHeight: number; 38 | allVisibleTasksHeight: number; 39 | }>({ 40 | refs: {}, 41 | allTasks: [], 42 | visibleTasks: [], 43 | style: {}, 44 | chartWidth: 0, 45 | clientHeight: 0, 46 | clientWidth: 0, 47 | outerHeight: 0, 48 | rowsHeight: 0, 49 | scrollBarHeight: 0, 50 | allVisibleTasksHeight: 0, 51 | 52 | options: Immutable(defaultOptions), 53 | ...Immutable(defaultState) 54 | }); 55 | -------------------------------------------------------------------------------- /src/GanttElasticEvents.ts: -------------------------------------------------------------------------------- 1 | import events from "events"; 2 | 3 | const emitEvent = new events.EventEmitter(); 4 | 5 | export { emitEvent }; 6 | -------------------------------------------------------------------------------- /src/components/Chart/Calendar/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarRowItems, CalendarRowText } from "@/components/interfaces"; 2 | import { 3 | calculateCalendarDimensions, 4 | getMonthsCount, 5 | howManyDaysFit, 6 | howManyHoursFit, 7 | howManyMonthsFit 8 | } from "@/components/utils/times"; 9 | import GanttElasticContext from "@/GanttElasticContext"; 10 | import dayjs from "dayjs"; 11 | import _ from "lodash"; 12 | import React, { 13 | useContext, 14 | useEffect, 15 | useLayoutEffect, 16 | useMemo, 17 | useRef 18 | } from "react"; 19 | import invariant from "ts-invariant"; 20 | import CalendarRow from "./CalendarRow"; 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 23 | export interface CalendarProps {} 24 | 25 | export interface CalendarState { 26 | hours: CalendarRowItems[]; 27 | days: CalendarRowItems[]; 28 | months: CalendarRowItems[]; 29 | } 30 | 31 | const Calendar: React.FC = () => { 32 | const { 33 | refs, 34 | style, 35 | dispatch, 36 | options, 37 | chartWidth, 38 | times, 39 | calendar 40 | } = useContext(GanttElasticContext); 41 | 42 | // refs 43 | const chartCalendarContainerRef = useRef(null); 44 | 45 | useEffect(() => { 46 | refs.chartCalendarContainer = chartCalendarContainerRef; 47 | }, [refs]); 48 | 49 | /** 50 | * Generate hours 51 | * 52 | * @returns {array} 53 | */ 54 | const hours = useMemo(() => { 55 | const allHours: CalendarRowItems[] = []; 56 | 57 | if (options.calendar.hour.display) { 58 | const { widths, maxWidths, formatted } = calendar.hour; 59 | const dayRowHeight = options.calendar.day.height; 60 | const monthRowHeight = options.calendar.month.height; 61 | const rowHeight = options.calendar.hour.height; 62 | const format = options.calendar.hour.format; 63 | const steps = times.steps; 64 | for ( 65 | let hourIndex = 0, len = steps.length; 66 | hourIndex < len; 67 | hourIndex++ 68 | ) { 69 | const hoursCount = howManyHoursFit( 70 | format, 71 | steps[hourIndex].width.px, 72 | maxWidths 73 | ); 74 | if (hoursCount.count === 0) { 75 | continue; 76 | } 77 | const hours: CalendarRowItems = { 78 | key: hourIndex + "step", 79 | children: [] 80 | }; 81 | const hourStep = 24 / hoursCount.count; 82 | const hourWidthPx = steps[hourIndex].width.px / hoursCount.count; 83 | for (let i = 0, len = hoursCount.count; i < len; i++) { 84 | const hour = i * hourStep; 85 | let index = hourIndex; 86 | if (hourIndex > 0) { 87 | index = hourIndex - Math.floor(hourIndex / 24) * 24; 88 | } 89 | let textWidth = 0; 90 | if (typeof widths[index] !== "undefined") { 91 | textWidth = widths[index][hoursCount.type]; 92 | } 93 | const x = steps[hourIndex].offset.px + hourWidthPx * i; 94 | 95 | hours.children.push({ 96 | index: hourIndex, 97 | key: "h" + i, 98 | x, 99 | y: dayRowHeight + monthRowHeight, 100 | width: hourWidthPx, 101 | textWidth, 102 | height: rowHeight, 103 | label: formatted[hoursCount.type][hour] 104 | }); 105 | } 106 | allHours.push(hours); 107 | } 108 | } 109 | return allHours; 110 | }, [ 111 | calendar.hour, 112 | options.calendar.day.height, 113 | options.calendar.hour.display, 114 | options.calendar.hour.format, 115 | options.calendar.hour.height, 116 | options.calendar.month.height, 117 | times.steps 118 | ]); 119 | 120 | /** 121 | * Generate days 122 | * 123 | * @returns {array} 124 | */ 125 | const days = useMemo(() => { 126 | let allDays: CalendarRowItems[] = []; 127 | 128 | if (options.calendar.day.display) { 129 | const { widths, maxWidths } = calendar.day; 130 | const monthRowHeight = options.calendar.month.height; 131 | const rowHeight = options.calendar.day.height; 132 | const format = options.calendar.day.format; 133 | const steps = times.steps; 134 | 135 | const localeName = options.locale.name; 136 | 137 | const days: CalendarRowText[] = []; 138 | const daysCount = howManyDaysFit( 139 | format, 140 | chartWidth, 141 | maxWidths, 142 | steps.length 143 | ); 144 | if (daysCount.count === 0) { 145 | return allDays; 146 | } 147 | 148 | const dayStep = Math.ceil(steps.length / daysCount.count); 149 | for ( 150 | let dayIndex = 0, len = steps.length; 151 | dayIndex < len; 152 | dayIndex += dayStep 153 | ) { 154 | let dayWidthPx = 0; 155 | // day could be shorter (daylight saving time) so join widths and divide 156 | for (let currentStep = 0; currentStep < dayStep; currentStep++) { 157 | if (typeof steps[dayIndex + currentStep] !== "undefined") { 158 | dayWidthPx += steps[dayIndex + currentStep].width.px; 159 | } 160 | } 161 | const date = dayjs(steps[dayIndex].time); 162 | let textWidth = 0; 163 | if (typeof widths[dayIndex] !== "undefined") { 164 | textWidth = widths[dayIndex][daysCount.type]; 165 | } 166 | const x = steps[dayIndex].offset.px; 167 | days.push({ 168 | index: dayIndex, 169 | key: steps[dayIndex].time + "d", 170 | x, 171 | y: monthRowHeight, 172 | width: dayWidthPx, 173 | textWidth, 174 | height: rowHeight, 175 | label: format[daysCount.type](date.locale(localeName)) 176 | }); 177 | } 178 | allDays = _.map(days, item => ({ 179 | key: item.key, 180 | children: [item] 181 | })); 182 | } 183 | return allDays; 184 | }, [ 185 | calendar.day, 186 | chartWidth, 187 | options.calendar.day.display, 188 | options.calendar.day.format, 189 | options.calendar.day.height, 190 | options.calendar.month.height, 191 | options.locale.name, 192 | times.steps 193 | ]); 194 | 195 | /** 196 | * Generate months 197 | * 198 | * @returns {array} 199 | */ 200 | const months = useMemo(() => { 201 | let allMonths: CalendarRowItems[] = []; 202 | if (options.calendar.month.display) { 203 | const { widths, maxWidths } = calendar.month; 204 | const rowHeight = options.calendar.month.height; 205 | const format = options.calendar.month.format; 206 | const steps = times.steps; 207 | const firstTime = times.firstTime; 208 | const lastTime = times.lastTime; 209 | const localeName = options.locale.name; 210 | 211 | const months: CalendarRowText[] = []; 212 | 213 | const count = getMonthsCount(firstTime, lastTime); 214 | const monthsCount = howManyMonthsFit( 215 | format, 216 | chartWidth, 217 | maxWidths, 218 | count 219 | ); 220 | if (monthsCount.count === 0) { 221 | return allMonths; 222 | } 223 | let currentDate = dayjs(firstTime); 224 | const _lastTime = dayjs(lastTime); 225 | for (let monthIndex = 0; monthIndex < monthsCount.count; monthIndex++) { 226 | let monthWidth = 0; 227 | let monthOffset = Number.MAX_SAFE_INTEGER; 228 | let finalDate = dayjs(currentDate) 229 | .add(1, "month") 230 | .startOf("month"); 231 | if (finalDate.valueOf() > _lastTime.valueOf()) { 232 | finalDate = _lastTime; 233 | } 234 | // we must find first and last step to get the offsets / widths 235 | for (let step = 0, len = steps.length; step < len; step++) { 236 | const currentStep = steps[step]; 237 | if ( 238 | currentStep.time >= currentDate.valueOf() && 239 | currentStep.time < finalDate.valueOf() 240 | ) { 241 | monthWidth += currentStep.width.px; 242 | if (currentStep.offset.px < monthOffset) { 243 | monthOffset = currentStep.offset.px; 244 | } 245 | } 246 | } 247 | let label = ""; 248 | let choosenFormatName; 249 | for (const formatName in format) { 250 | if (maxWidths[formatName] + 2 <= monthWidth) { 251 | label = format[formatName](currentDate.locale(localeName)); 252 | choosenFormatName = formatName; 253 | } 254 | } 255 | let textWidth = 0; 256 | if (typeof widths[monthIndex] !== "undefined" && choosenFormatName) { 257 | textWidth = widths[monthIndex][choosenFormatName]; 258 | } 259 | const x = monthOffset; 260 | months.push({ 261 | index: monthIndex, 262 | key: monthIndex + "m", 263 | x, 264 | y: 0, 265 | width: monthWidth, 266 | textWidth, 267 | choosenFormatName, 268 | height: rowHeight, 269 | label 270 | }); 271 | currentDate = currentDate.add(1, "month").startOf("month"); 272 | if (currentDate.valueOf() > _lastTime.valueOf()) { 273 | currentDate = _lastTime; 274 | } 275 | } 276 | allMonths = _.map(months, item => ({ 277 | key: item.key, 278 | children: [item] 279 | })); 280 | } 281 | return allMonths; 282 | }, [ 283 | calendar.month, 284 | chartWidth, 285 | options.calendar.month.display, 286 | options.calendar.month.format, 287 | options.calendar.month.height, 288 | options.locale.name, 289 | times.firstTime, 290 | times.lastTime, 291 | times.steps 292 | ]); 293 | 294 | useLayoutEffect(() => { 295 | const height = calculateCalendarDimensions( 296 | hours, 297 | days, 298 | months, 299 | options.asMutable({ deep: true }) 300 | ); 301 | if (calendar.height !== height) { 302 | invariant.warn(`set calendar's height:${height}`); 303 | dispatch && 304 | dispatch({ 305 | type: "update-calendar-height", 306 | payload: height 307 | }); 308 | } 309 | }, [calendar.height, days, dispatch, hours, months, options]); 310 | 311 | return useMemo( 312 | () => ( 313 |
320 |
327 | {options.calendar.month.display && ( 328 | 329 | )} 330 | {options.calendar.day.display && ( 331 | 332 | )} 333 | {options.calendar.hour.display && ( 334 | 335 | )} 336 |
337 |
338 | ), 339 | [ 340 | days, 341 | hours, 342 | months, 343 | chartWidth, 344 | options.calendar.day.display, 345 | options.calendar.hour.display, 346 | options.calendar.month.display, 347 | style 348 | ] 349 | ); 350 | }; 351 | 352 | export default Calendar; 353 | -------------------------------------------------------------------------------- /src/components/Chart/Calendar/CalendarRow.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarRowItems } from "@/components/interfaces"; 2 | import GanttElasticContext from "@/GanttElasticContext"; 3 | import _ from "lodash"; 4 | import React, { useContext, useMemo } from "react"; 5 | import RowText from "./RowText"; 6 | 7 | interface CalendarRowProps { 8 | items: Array; 9 | which: string; 10 | } 11 | 12 | const CalendarRow: React.FC = ({ items, which }) => { 13 | const { style } = useContext(GanttElasticContext); 14 | 15 | return useMemo(() => { 16 | const rowStyle = { 17 | ...style["calendar-row"], 18 | ...style["calendar-row--" + which] 19 | }; 20 | 21 | const rectStyle = { 22 | ...style["calendar-row-rect"], 23 | ...style["calendar-row-rect--" + which] 24 | }; 25 | 26 | return ( 27 |
33 | {_.map(items, item => ( 34 |
42 | {_.map(item.children, child => ( 43 | 44 | ))} 45 |
46 | ))} 47 |
48 | ); 49 | }, [items, style, which]); 50 | }; 51 | 52 | export default CalendarRow; 53 | -------------------------------------------------------------------------------- /src/components/Chart/Calendar/RowText.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarRowText } from "@/components/interfaces"; 2 | import { isInsideViewPort } from "@/components/utils/charts"; 3 | import GanttElasticContext from "@/GanttElasticContext"; 4 | import React, { useContext, useMemo } from "react"; 5 | 6 | export interface CalendarRowTextProps { 7 | item: CalendarRowText; 8 | which: string; 9 | } 10 | 11 | const RowText: React.FC = ({ item, which }) => { 12 | const { style, scroll } = useContext(GanttElasticContext); 13 | 14 | return useMemo(() => { 15 | const rectChildStyle = { 16 | ...style["calendar-row-rect-child"], 17 | ...style["calendar-row-rect-child--" + which], 18 | width: item.width + "px", 19 | height: item.height + "px" 20 | }; 21 | 22 | const textStyle = { 23 | ...style["calendar-row-text"], 24 | ...style["calendar-row-text--" + which] 25 | }; 26 | 27 | if (which === "month") { 28 | let x = item.x + item.width / 2 - item.textWidth / 2; 29 | if ( 30 | which === "month" && 31 | isInsideViewPort( 32 | scroll.chart.left, 33 | scroll.chart.right, 34 | item.x, 35 | item.width, 36 | 0 37 | ) 38 | ) { 39 | const scrollWidth = scroll.chart.right - scroll.chart.left; 40 | x = scroll.chart.left + scrollWidth / 2 - item.textWidth / 2 + 2; 41 | if (x + item.textWidth + 2 > item.x + item.width) { 42 | x = item.x + item.width - item.textWidth - 2; 43 | } else if (x < item.x) { 44 | x = item.x + 2; 45 | } 46 | } 47 | textStyle.left = x - item.x + "px"; 48 | } 49 | 50 | return ( 51 |
58 |
65 | {item.label} 66 |
67 |
68 | ); 69 | }, [ 70 | item.height, 71 | item.label, 72 | item.textWidth, 73 | item.width, 74 | item.x, 75 | scroll.chart.left, 76 | scroll.chart.right, 77 | style, 78 | which 79 | ]); 80 | }; 81 | 82 | export default RowText; 83 | -------------------------------------------------------------------------------- /src/components/Chart/Chart.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import _ from "lodash"; 3 | import React, { useContext, useEffect, useMemo, useRef } from "react"; 4 | import Calendar from "./Calendar/Calendar"; 5 | import DaysHighlight from "./DaysHighlight"; 6 | import DependencyLines from "./DependencyLines"; 7 | import Grid from "./Grid"; 8 | import Milestone from "./Row/Milestone"; 9 | import Project from "./Row/Project"; 10 | import Task from "./Row/Task"; 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 13 | export interface ChartProps {} 14 | 15 | const Chart: React.FC = () => { 16 | const { 17 | style, 18 | visibleTasks, 19 | refs, 20 | calendar, 21 | clientHeight, 22 | chartWidth, 23 | rowsHeight, 24 | allVisibleTasksHeight, 25 | options 26 | } = useContext(GanttElasticContext); 27 | 28 | const chartRef = useRef(null); 29 | const chartCalendarContainerRef = useRef(null); 30 | const chartGraphContainerRef = useRef(null); 31 | const chartGraphRef = useRef(null); 32 | const chartGraphSvgRef = useRef(null); 33 | 34 | useEffect(() => { 35 | refs.chart = chartRef; 36 | refs.chartCalendarContainer = chartCalendarContainerRef; 37 | refs.chartGraphContainer = chartGraphContainerRef; 38 | refs.chartGraph = chartGraphRef; 39 | refs.chartGraphSvg = chartGraphSvgRef; 40 | }, [refs]); 41 | 42 | const renderTasks = useMemo(() => { 43 | return ( 44 | 56 | 57 | 58 | 59 | {_.map(visibleTasks, task => { 60 | return ( 61 | 68 | {task.type === "task" && } 69 | {task.type === "project" && } 70 | {task.type === "milestone" && } 71 | 72 | ); 73 | })} 74 | 75 | ); 76 | }, [style, chartWidth, allVisibleTasksHeight, visibleTasks]); 77 | 78 | return useMemo( 79 | () => ( 80 |
85 |
94 | 95 |
96 |
104 |
111 |
119 | {renderTasks} 120 |
121 |
122 |
123 |
124 | ), 125 | [ 126 | calendar.height, 127 | clientHeight, 128 | options.calendar.gap, 129 | renderTasks, 130 | rowsHeight, 131 | style, 132 | chartWidth 133 | ] 134 | ); 135 | }; 136 | 137 | export default Chart; 138 | -------------------------------------------------------------------------------- /src/components/Chart/DaysHighlight.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import dayjs from "dayjs"; 3 | import _ from "lodash"; 4 | import React, { useContext, useMemo } from "react"; 5 | 6 | /** 7 | * Show working days? 8 | * 9 | * @returns {bool} 10 | */ 11 | const showWorkingDays = (workingDays: number[]): boolean => { 12 | if ( 13 | typeof workingDays !== "undefined" && 14 | Array.isArray(workingDays) && 15 | workingDays.length 16 | ) { 17 | return true; 18 | } 19 | return false; 20 | }; 21 | 22 | /** 23 | * Get key 24 | * 25 | * @param {object} day 26 | * @returns {string} key ideintifier for loop 27 | */ 28 | function getKey( 29 | time: string | number | Date | dayjs.Dayjs | undefined 30 | ): string { 31 | return dayjs(time).format("YYYY-MM-DD"); 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 35 | export interface DaysHighlightProps {} 36 | 37 | const DaysHighlight: React.FC = () => { 38 | const { style, options, times } = useContext(GanttElasticContext); 39 | 40 | const { workingDays } = options.calendar; 41 | 42 | return useMemo(() => { 43 | /** 44 | * Get working days 45 | */ 46 | const workingSteps = _.filter(times.steps, step => { 47 | return !workingDays.includes(dayjs(step.time).day()); 48 | }); 49 | 50 | return ( 51 | 55 | {showWorkingDays(workingDays.asMutable()) && 56 | _.map(workingSteps, day => ( 57 | 68 | ))} 69 | 70 | ); 71 | }, [style, times.steps, workingDays]); 72 | }; 73 | 74 | export default DaysHighlight; 75 | -------------------------------------------------------------------------------- /src/components/Chart/DependencyLines.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import _ from "lodash"; 3 | import React, { useContext, useMemo } from "react"; 4 | import { Task } from "../interfaces"; 5 | 6 | /** 7 | * Get path points 8 | */ 9 | const getPoints = ( 10 | fromTaskId: string | number, 11 | toTaskId: string | number, 12 | isTaskVisible?: (task: Task) => boolean, 13 | getTask?: (id: string | number) => Task 14 | ): string | undefined => { 15 | if (getTask && isTaskVisible) { 16 | const fromTask = getTask(fromTaskId); 17 | const toTask = getTask(toTaskId); 18 | if ( 19 | fromTask === null || 20 | toTask === null || 21 | !isTaskVisible(toTask) || 22 | !isTaskVisible(fromTask) 23 | ) { 24 | return; 25 | } 26 | const startX = fromTask.x + fromTask.width; 27 | const startY = fromTask.y + fromTask.height / 2; 28 | const stopX = toTask.x; 29 | const stopY = toTask.y + toTask.height / 2; 30 | const distanceX = stopX - startX; 31 | let distanceY; 32 | let yMultiplier = 1; 33 | if (stopY >= startY) { 34 | distanceY = stopY - startY; 35 | } else { 36 | distanceY = startY - stopY; 37 | yMultiplier = -1; 38 | } 39 | const offset = 10; 40 | const roundness = 4; 41 | const isBefore = distanceX <= offset + roundness; 42 | let points = `M ${startX} ${startY} 43 | L ${startX + offset},${startY} `; 44 | if (isBefore) { 45 | points += `Q ${startX + offset + roundness},${startY} ${startX + 46 | offset + 47 | roundness},${startY + roundness * yMultiplier} 48 | L ${startX + offset + roundness},${startY + 49 | (distanceY * yMultiplier) / 2 - 50 | roundness * yMultiplier} 51 | Q ${startX + offset + roundness},${startY + 52 | (distanceY * yMultiplier) / 2} ${startX + offset},${startY + 53 | (distanceY * yMultiplier) / 2} 54 | L ${startX - offset + distanceX},${startY + 55 | (distanceY * yMultiplier) / 2} 56 | Q ${startX - offset + distanceX - roundness},${startY + 57 | (distanceY * yMultiplier) / 2} ${startX - 58 | offset + 59 | distanceX - 60 | roundness},${startY + 61 | (distanceY * yMultiplier) / 2 + 62 | roundness * yMultiplier} 63 | L ${startX - offset + distanceX - roundness},${stopY - 64 | roundness * yMultiplier} 65 | Q ${startX - offset + distanceX - roundness},${stopY} ${startX - 66 | offset + 67 | distanceX},${stopY} 68 | L ${stopX},${stopY}`; 69 | } else { 70 | points += `L ${startX + distanceX / 2 - roundness},${startY} 71 | Q ${startX + distanceX / 2},${startY} ${startX + 72 | distanceX / 2},${startY + roundness * yMultiplier} 73 | L ${startX + distanceX / 2},${stopY - roundness * yMultiplier} 74 | Q ${startX + distanceX / 2},${stopY} ${startX + 75 | distanceX / 2 + 76 | roundness},${stopY} 77 | L ${stopX},${stopY}`; 78 | } 79 | return points; 80 | } 81 | }; 82 | 83 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 84 | export interface DaysHighlightProps {} 85 | 86 | const DependencyLines: React.FC = () => { 87 | const { style, allTasks, isTaskVisible, getTask } = useContext( 88 | GanttElasticContext 89 | ); 90 | 91 | /** 92 | * Get tasks which are dependent on other tasks 93 | * 94 | * @returns {array} 95 | */ 96 | const dependencyTasks = useMemo(() => { 97 | return _.filter(allTasks, task => !task.dependentOn) 98 | .map(task => { 99 | task.dependencyLines = _.map(task.dependentOn, id => ({ 100 | points: getPoints(id, task.id, isTaskVisible, getTask), 101 | taskId: id 102 | })); 103 | return task; 104 | }) 105 | .filter(task => task.dependencyLines); 106 | }, [allTasks, isTaskVisible, getTask]); 107 | 108 | return useMemo(() => { 109 | return ( 110 | 118 | {_.map(dependencyTasks, task => ( 119 | 120 | {_.map(task.dependencyLines, (dependencyLine, index) => ( 121 | 133 | ))} 134 | 135 | ))} 136 | 137 | ); 138 | }, [dependencyTasks, style]); 139 | }; 140 | 141 | export default DependencyLines; 142 | -------------------------------------------------------------------------------- /src/components/Chart/Grid.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import _ from "lodash"; 3 | import React, { useContext, useEffect, useMemo, useRef } from "react"; 4 | import { isInsideViewPort, timeToPixelOffsetX } from "../utils/charts"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 7 | export interface ChartProps {} 8 | 9 | const Grid: React.FC = () => { 10 | const { 11 | style, 12 | options, 13 | refs, 14 | visibleTasks, 15 | allTasks, 16 | times, 17 | scroll, 18 | chartWidth, 19 | allVisibleTasksHeight 20 | } = useContext(GanttElasticContext); 21 | const allTaskCount = allTasks.length; 22 | // refs 23 | const svgChartRef = useRef(null); 24 | 25 | useEffect(() => { 26 | refs.svgChart = svgChartRef; 27 | }, [refs]); 28 | 29 | const strokeWidth = useMemo( 30 | () => parseInt(`${style["grid-line-vertical"]["strokeWidth"]}`), 31 | [style] 32 | ); 33 | 34 | /** 35 | * Generate vertical lines of the grid 36 | */ 37 | const renderVerticalLines = useMemo(() => { 38 | const lines: JSX.Element[] = []; 39 | _.forEach(times.steps, step => { 40 | if ( 41 | isInsideViewPort( 42 | scroll.chart.left, 43 | scroll.chart.right, 44 | step.offset.px, 45 | 1 46 | ) 47 | ) { 48 | lines.push( 49 | 62 | ); 63 | } 64 | }); 65 | return {lines}; 66 | }, [ 67 | allTaskCount, 68 | options.chart.grid.horizontal.gap, 69 | options.row.height, 70 | scroll.chart.left, 71 | scroll.chart.right, 72 | strokeWidth, 73 | style, 74 | times.steps 75 | ]); 76 | 77 | /** 78 | * Generate horizontal lines of the grid 79 | */ 80 | const renderHorizontalLines = useMemo(() => { 81 | return ( 82 | <> 83 | {_.map(visibleTasks, (_: object, index: number) => { 84 | const y = 85 | index * 86 | (options.row.height + options.chart.grid.horizontal.gap * 2) + 87 | strokeWidth / 2; 88 | 89 | return ( 90 | 99 | ); 100 | })} 101 | 102 | ); 103 | }, [ 104 | options.chart.grid.horizontal.gap, 105 | options.row.height, 106 | strokeWidth, 107 | style, 108 | visibleTasks 109 | ]); 110 | 111 | /** 112 | * Get current time line position 113 | * 114 | * @returns {object} 115 | */ 116 | const timeLinePosition = useMemo(() => { 117 | const d = new Date(); 118 | const current = d.getTime(); 119 | const currentOffset = timeToPixelOffsetX( 120 | current, 121 | times.firstTime, 122 | options.times.timePerPixel 123 | ); 124 | const timeLine = { 125 | x: 0, 126 | y1: 0, 127 | y2: "100%", 128 | dateTime: "", 129 | time: current 130 | }; 131 | timeLine.x = currentOffset; 132 | timeLine.dateTime = d.toLocaleDateString(); 133 | return timeLine; 134 | }, [options.times.timePerPixel, times.firstTime]); 135 | 136 | return useMemo( 137 | () => ( 138 | 148 | 152 | {renderHorizontalLines} 153 | {renderVerticalLines} 154 | 162 | 163 | 164 | ), 165 | [ 166 | allVisibleTasksHeight, 167 | renderHorizontalLines, 168 | renderVerticalLines, 169 | style, 170 | timeLinePosition.x, 171 | timeLinePosition.y1, 172 | timeLinePosition.y2, 173 | chartWidth 174 | ] 175 | ); 176 | }; 177 | 178 | export default Grid; 179 | -------------------------------------------------------------------------------- /src/components/Chart/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { Task } from "@/components/interfaces"; 2 | import GanttElasticContext from "@/GanttElasticContext"; 3 | import React, { useContext, useMemo } from "react"; 4 | 5 | export interface ProgressBarProps { 6 | task: Task; 7 | clipPath: string; 8 | } 9 | 10 | const ProgressBar: React.FC = ({ task, ...props }) => { 11 | const { style, options } = useContext(GanttElasticContext); 12 | 13 | return useMemo(() => { 14 | // Get progress width 15 | const progressWidth = task.progress + "%"; 16 | // Get line points 17 | const start = (task.width / 100) * task.progress; 18 | const linePoints = `M ${start} 0 L ${start} ${task.height}`; 19 | 20 | return ( 21 | 29 | 30 | 37 | 48 | 49 | 50 | {options.chart.progress.bar && ( 51 | 61 | )} 62 | {options.chart.progress.pattern && ( 63 | 64 | 75 | 84 | 85 | )} 86 | 87 | ); 88 | }, [ 89 | options.chart.progress.bar, 90 | options.chart.progress.pattern, 91 | options.chart.progress.width, 92 | style, 93 | task.height, 94 | task.progress, 95 | task.style, 96 | task.width 97 | ]); 98 | }; 99 | 100 | export default ProgressBar; 101 | -------------------------------------------------------------------------------- /src/components/Chart/Row/Milestone.tsx: -------------------------------------------------------------------------------- 1 | import Expander from "@/components/Expander"; 2 | import { Task } from "@/components/interfaces"; 3 | import GanttElasticContext from "@/GanttElasticContext"; 4 | import { emitEvent } from "@/GanttElasticEvents"; 5 | import React, { useCallback, useContext, useMemo } from "react"; 6 | import { invariant } from "ts-invariant"; 7 | import ProgressBar from "../ProgressBar"; 8 | import ChartText from "../Text"; 9 | 10 | export interface MilestoneProps { 11 | task: Task; 12 | } 13 | 14 | const ChartMilestone: React.FC = ({ task }) => { 15 | const { style, options } = useContext(GanttElasticContext); 16 | 17 | /** 18 | * Emit event 19 | * 20 | * @param {string} eventName 21 | * @param {Event} event 22 | */ 23 | const onEmitEvent = useCallback( 24 | event => { 25 | invariant.warn(event.type, task); 26 | emitEvent.emit(`chart-${task.type}-${event.type}`, { 27 | event, 28 | data: task 29 | }); 30 | task.events && 31 | task.events[event.type] && 32 | task.events[event.type](event, task); 33 | }, 34 | [task] 35 | ); 36 | 37 | /** 38 | * Get points 39 | */ 40 | const points = useMemo(() => { 41 | const fifty = task.height / 2; 42 | let offset = fifty; 43 | if (task.width / 2 - offset < 0) { 44 | offset = task.width / 2; 45 | } 46 | return `0,${fifty} 47 | ${offset},0 48 | ${task.width - offset},0 49 | ${task.width},${fifty} 50 | ${task.width - offset},${task.height} 51 | ${offset},${task.height}`; 52 | }, [task.height, task.width]); 53 | 54 | return useMemo(() => { 55 | const clipPathId = `gantt-elastic__milestone-clip-path-${task.id}`; 56 | 57 | const displayExpander = 58 | options.chart.expander.display || 59 | (options.chart.expander.displayIfTaskListHidden && 60 | !options.taskList.display); 61 | 62 | return ( 63 | 71 | {displayExpander && ( 72 | 87 | 97 | 98 | )} 99 | 124 | 125 | 126 | 127 | 128 | 129 | 139 | 143 | 144 | 145 | {options.chart.text.display && } 146 | 147 | ); 148 | }, [ 149 | onEmitEvent, 150 | options.chart.expander.display, 151 | options.chart.expander.displayIfTaskListHidden, 152 | options.chart.expander.offset, 153 | options.chart.expander.size, 154 | options.chart.expander.type, 155 | options.chart.text.display, 156 | options.row.height, 157 | options.taskList.display, 158 | points, 159 | style, 160 | task 161 | ]); 162 | }; 163 | 164 | export default ChartMilestone; 165 | -------------------------------------------------------------------------------- /src/components/Chart/Row/Project.tsx: -------------------------------------------------------------------------------- 1 | import Expander from "@/components/Expander"; 2 | import { Task } from "@/components/interfaces"; 3 | import GanttElasticContext from "@/GanttElasticContext"; 4 | import { emitEvent } from "@/GanttElasticEvents"; 5 | import React, { useCallback, useContext, useMemo } from "react"; 6 | import { invariant } from "ts-invariant"; 7 | import ProgressBar from "../ProgressBar"; 8 | import ChartText from "../Text"; 9 | 10 | export interface ProjectProps { 11 | task: Task; 12 | } 13 | 14 | const ChartProject: React.FC = ({ task }) => { 15 | const { style, options } = useContext(GanttElasticContext); 16 | 17 | /** 18 | * Emit event 19 | * 20 | * @param {string} eventName 21 | * @param {Event} event 22 | */ 23 | const onEmitEvent = useCallback( 24 | event => { 25 | invariant.warn(event.type, task); 26 | emitEvent.emit(`chart-${task.type}-${event.type}`, { 27 | event, 28 | data: task 29 | }); 30 | task.events && 31 | task.events[event.type] && 32 | task.events[event.type](event, task); 33 | }, 34 | [task] 35 | ); 36 | 37 | /** 38 | * Get points 39 | */ 40 | const points = useMemo(() => { 41 | const bottom = task.height - task.height / 4; 42 | const corner = task.height / 6; 43 | const smallCorner = task.height / 8; 44 | return `M ${smallCorner},0 45 | L ${task.width - smallCorner} 0 46 | L ${task.width} ${smallCorner} 47 | L ${task.width} ${bottom} 48 | L ${task.width - corner} ${task.height} 49 | L ${task.width - corner * 2} ${bottom} 50 | L ${corner * 2} ${bottom} 51 | L ${corner} ${task.height} 52 | L 0 ${bottom} 53 | L 0 ${smallCorner} 54 | Z 55 | `; 56 | }, [task.height, task.width]); 57 | 58 | return useMemo(() => { 59 | const clipPathId = `gantt-elastic__project-clip-path-${task.id}`; 60 | 61 | const displayExpander = 62 | options.chart.expander.display || 63 | (options.chart.expander.displayIfTaskListHidden && 64 | !options.taskList.display); 65 | 66 | return ( 67 | 75 | {displayExpander && ( 76 | 91 | 101 | 102 | )} 103 | 128 | 129 | 130 | 131 | 132 | 133 | 143 | 147 | 148 | {options.chart.text.display && } 149 | 150 | ); 151 | }, [ 152 | onEmitEvent, 153 | options.chart.expander.display, 154 | options.chart.expander.displayIfTaskListHidden, 155 | options.chart.expander.offset, 156 | options.chart.expander.size, 157 | options.chart.expander.type, 158 | options.chart.text.display, 159 | options.row.height, 160 | options.taskList.display, 161 | points, 162 | style, 163 | task 164 | ]); 165 | }; 166 | 167 | export default ChartProject; 168 | -------------------------------------------------------------------------------- /src/components/Chart/Row/Task.tsx: -------------------------------------------------------------------------------- 1 | import Expander from "@/components/Expander"; 2 | import { Task } from "@/components/interfaces"; 3 | import GanttElasticContext from "@/GanttElasticContext"; 4 | import { emitEvent } from "@/GanttElasticEvents"; 5 | import React, { useCallback, useContext, useMemo } from "react"; 6 | import invariant from "ts-invariant"; 7 | import ProgressBar from "../ProgressBar"; 8 | import ChartText from "../Text"; 9 | 10 | export interface TaskProps { 11 | task: Task; 12 | } 13 | 14 | const ChartTask: React.FC = ({ task }) => { 15 | const { style, options } = useContext(GanttElasticContext); 16 | 17 | /** 18 | * Emit event 19 | * 20 | * @param {string} eventName 21 | * @param {Event} event 22 | */ 23 | const onEmitEvent = useCallback( 24 | event => { 25 | invariant.warn(event.type, task); 26 | emitEvent.emit(`chart-${task.type}-${event.type}`, { 27 | event, 28 | data: task 29 | }); 30 | task.events && 31 | task.events[event.type] && 32 | task.events[event.type](event, task); 33 | }, 34 | [task] 35 | ); 36 | 37 | return useMemo(() => { 38 | const points = `0,0 ${task.width},0 ${task.width},${task.height} 0,${task.height}`; 39 | 40 | const clipPathId = `gantt-elastic__project-clip-path-${task.id}`; 41 | 42 | const displayExpander = 43 | options.chart.expander.display || 44 | (options.chart.expander.displayIfTaskListHidden && 45 | !options.taskList.display); 46 | 47 | return ( 48 | 56 | {displayExpander && ( 57 | 73 | 83 | 84 | )} 85 | 110 | 111 | 112 | 113 | 114 | 115 | 125 | 129 | 130 | {options.chart.text.display && } 131 | 132 | ); 133 | }, [ 134 | onEmitEvent, 135 | options.chart.expander.display, 136 | options.chart.expander.displayIfTaskListHidden, 137 | options.chart.expander.offset, 138 | options.chart.expander.size, 139 | options.chart.expander.type, 140 | options.chart.text.display, 141 | options.row.height, 142 | options.taskList.display, 143 | style, 144 | task 145 | ]); 146 | }; 147 | 148 | export default ChartTask; 149 | -------------------------------------------------------------------------------- /src/components/Chart/Text.tsx: -------------------------------------------------------------------------------- 1 | import { Task } from "@/components/interfaces"; 2 | import GanttElasticContext from "@/GanttElasticContext"; 3 | import React, { useContext, useMemo } from "react"; 4 | 5 | export interface TaskProps { 6 | task: Task; 7 | } 8 | 9 | const ChartText: React.FC = ({ task }) => { 10 | const { style, options, ctx } = useContext(GanttElasticContext); 11 | 12 | /** 13 | * Get width 14 | * 15 | * @returns {number} 16 | */ 17 | const width = useMemo(() => { 18 | if (ctx) { 19 | const textStyle = style["chart-row-text"]; 20 | ctx.font = `${textStyle["fontWight"]} ${textStyle["fontSize"]} ${textStyle["fontFamily"]}`; 21 | const textWidth = ctx.measureText(task.label).width; 22 | return textWidth + options.chart.text.xPadding * 2; 23 | } 24 | }, [ctx, options.chart.text.xPadding, style, task.label]); 25 | 26 | return useMemo(() => { 27 | const height = task.height + options.chart.grid.horizontal.gap * 2; 28 | 29 | return ( 30 | 38 | 39 |
44 |
53 |
{task.label}
54 |
55 |
56 |
57 |
58 | ); 59 | }, [ 60 | options.chart.grid.horizontal.gap, 61 | options.chart.text.offset, 62 | style, 63 | task.height, 64 | task.label, 65 | task.width, 66 | task.x, 67 | task.y, 68 | width 69 | ]); 70 | }; 71 | 72 | export default ChartText; 73 | -------------------------------------------------------------------------------- /src/components/Expander.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import { emitEvent } from "@/GanttElasticEvents"; 3 | import _ from "lodash"; 4 | import React, { useContext, useMemo } from "react"; 5 | import { Task } from "./interfaces"; 6 | 7 | /** 8 | * Is current expander collapsed? 9 | * 10 | * @param tasks 11 | */ 12 | function collapseState(tasks: Array): boolean { 13 | if (tasks.length === 0) { 14 | return false; 15 | } 16 | let collapsed = 0; 17 | for (let i = 0, len = tasks.length; i < len; i++) { 18 | if (tasks[i].collapsed) { 19 | collapsed++; 20 | } 21 | } 22 | return collapsed === tasks.length; 23 | } 24 | 25 | /** 26 | * Get specific class prefix 27 | * 28 | * @param type 29 | * @param full 30 | */ 31 | function getClassPrefix(type: string, full = true): string { 32 | return `${full ? "gantt-elastic__" : ""}${type}-expander`; 33 | } 34 | 35 | export interface ExpanderProps { 36 | type: string; 37 | options: { 38 | type: string; 39 | size: number; 40 | padding: number; 41 | margin: number; 42 | }; 43 | tasks: Array; 44 | } 45 | 46 | const state = { 47 | border: 0.5, 48 | borderStyle: { 49 | strokeWidth: 0.5 50 | }, 51 | lineOffset: 5 52 | }; 53 | 54 | const Expander: React.FC = ({ type, tasks, options }) => { 55 | const { style, dispatch } = useContext(GanttElasticContext); 56 | 57 | const { collapsed, toggle, allChildren } = useMemo(() => { 58 | const collapsed = collapseState(tasks); 59 | /** 60 | * Toggle expander 61 | */ 62 | const toggle = (): void => { 63 | if (tasks.length === 0) { 64 | return; 65 | } 66 | // const collapse = !collapsed; 67 | // tasks.forEach(task => { 68 | // task.collapsed = collapse; 69 | // }); 70 | // dispatch({ 71 | // type: "taskList-collapsed-change", 72 | // payload: {} 73 | // }); 74 | emitEvent.emit("taskList-collapsed-change", tasks, !collapsed); 75 | }; 76 | /** 77 | * Get all tasks 78 | */ 79 | const allChildren: Array = []; 80 | _.forEach(tasks, task => { 81 | _.forEach(task.allChildren, childId => { 82 | allChildren.push(childId); 83 | }); 84 | }); 85 | 86 | return { collapsed, toggle, allChildren }; 87 | }, [tasks]); 88 | 89 | return useMemo(() => { 90 | const fullClassPrefix = getClassPrefix(options.type); 91 | const notFullClassPrefix = getClassPrefix(options.type, false); 92 | 93 | const taskListStyle = 94 | type !== "taskList" 95 | ? {} 96 | : { 97 | paddingLeft: 98 | tasks[0]?.parents.length * options.padding + 99 | options.margin + 100 | "px", 101 | margin: "auto 0" 102 | }; 103 | return ( 104 |
111 | {allChildren.length > 0 && ( 112 | 119 | 132 | 140 | {collapsed && ( 141 | 149 | )} 150 | 151 | )} 152 |
153 | ); 154 | }, [ 155 | allChildren.length, 156 | collapsed, 157 | options.margin, 158 | options.padding, 159 | options.size, 160 | options.type, 161 | style, 162 | tasks, 163 | toggle, 164 | type 165 | ]); 166 | }; 167 | 168 | export default Expander; 169 | -------------------------------------------------------------------------------- /src/components/MainView.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import { emitEvent } from "@/GanttElasticEvents"; 3 | import React, { 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useMemo, 8 | useRef, 9 | useState 10 | } from "react"; 11 | import Chart from "./Chart/Chart"; 12 | import TaskList from "./TaskList/TaskList"; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 15 | export interface MainViewProps {} 16 | 17 | interface MousePos { 18 | x: number; 19 | y: number; 20 | movementX: number; 21 | movementY: number; 22 | lastX: number; 23 | lastY: number; 24 | positiveX: number; 25 | positiveY: number; 26 | currentX: number; 27 | currentY: number; 28 | } 29 | 30 | const MainView: React.FC = () => { 31 | // gantt context 32 | const { 33 | style, 34 | refs, 35 | options, 36 | chartWidth, 37 | clientHeight, 38 | clientWidth, 39 | rowsHeight, 40 | scrollBarHeight, 41 | allVisibleTasksHeight, 42 | taskList, 43 | calendar 44 | } = useContext(GanttElasticContext); 45 | 46 | const [scroll] = useState({ scrolling: false }); 47 | 48 | // Chart拖动事件数据,不需要重新渲染页面 49 | const [mousePos] = useState({ 50 | x: 0, 51 | y: 0, 52 | movementX: 0, 53 | movementY: 0, 54 | lastX: 0, 55 | lastY: 0, 56 | positiveX: 0, 57 | positiveY: 0, 58 | currentX: 0, 59 | currentY: 0 60 | }); 61 | 62 | /** 63 | * refs 64 | */ 65 | const mainViewRef = useRef(null); 66 | const taskListRef = useRef(null); 67 | const chartContainerRef = useRef(null); 68 | const chartScrollContainerVerticalRef = useRef(null); 69 | const chartScrollContainerHorizontalRef = useRef(null); 70 | 71 | useEffect(() => { 72 | refs.mainView = mainViewRef; 73 | refs.taskList = taskListRef; 74 | refs.chartContainer = chartContainerRef; 75 | refs.chartScrollContainerVertical = chartScrollContainerVerticalRef; 76 | refs.chartScrollContainerHorizontal = chartScrollContainerHorizontalRef; 77 | }, [refs]); 78 | 79 | const { 80 | mouseMove, 81 | mouseUp, 82 | onHorizontalScroll, 83 | onVerticalScroll, 84 | chartWheel 85 | } = useMemo(() => { 86 | /** 87 | * Emit event when mouse is moving inside main view 88 | */ 89 | const mouseMove = ( 90 | event: React.MouseEvent 91 | ): void => { 92 | emitEvent.emit("main-view-mousemove", event); 93 | }; 94 | 95 | /** 96 | * Emit mouseup event inside main view 97 | */ 98 | const mouseUp = ( 99 | event: React.MouseEvent 100 | ): void => { 101 | scroll.scrolling = false; 102 | emitEvent.emit("main-view-mouseup", event); 103 | }; 104 | 105 | /** 106 | * Horizontal scroll event handler 107 | */ 108 | const onHorizontalScroll = (event: React.UIEvent): void => { 109 | emitEvent.emit("chart-scroll-horizontal", event); 110 | }; 111 | 112 | /** 113 | * Vertical scroll event handler 114 | */ 115 | const onVerticalScroll = (event: React.UIEvent): void => { 116 | emitEvent.emit("chart-scroll-vertical", event); 117 | }; 118 | 119 | /** 120 | * Mouse wheel event handler 121 | */ 122 | const chartWheel = (event: React.WheelEvent): void => { 123 | emitEvent.emit("chart-wheel", event); 124 | }; 125 | 126 | return { 127 | mouseMove, 128 | mouseUp, 129 | onHorizontalScroll, 130 | onVerticalScroll, 131 | chartWheel 132 | }; 133 | }, [scroll]); 134 | 135 | /** 136 | * Chart mousedown event handler 137 | * Initiates drag scrolling mode 138 | */ 139 | const chartMouseDown = useCallback( 140 | ev => { 141 | if (typeof ev.touches !== "undefined") { 142 | mousePos.x = mousePos.lastX = ev.touches[0].screenX; 143 | mousePos.y = mousePos.lastY = ev.touches[0].screenY; 144 | mousePos.movementX = 0; 145 | mousePos.movementY = 0; 146 | const horizontal = chartScrollContainerHorizontalRef.current; 147 | const vertical = chartScrollContainerVerticalRef.current; 148 | if (horizontal && vertical) { 149 | mousePos.currentX = horizontal.scrollLeft; 150 | mousePos.currentY = vertical.scrollTop; 151 | } 152 | } 153 | scroll.scrolling = true; 154 | }, 155 | [ 156 | mousePos.currentX, 157 | mousePos.currentY, 158 | mousePos.lastX, 159 | mousePos.lastY, 160 | mousePos.movementX, 161 | mousePos.movementY, 162 | mousePos.x, 163 | mousePos.y, 164 | scroll 165 | ] 166 | ); 167 | 168 | /** 169 | * Chart mouseup event handler 170 | * Deactivates drag scrolling mode 171 | */ 172 | const chartMouseUp = useCallback( 173 | ev => { 174 | scroll.scrolling = false; 175 | }, 176 | [scroll] 177 | ); 178 | 179 | /** 180 | * Chart mousemove event handler 181 | * When in drag scrolling mode this method calculate scroll movement 182 | */ 183 | const chartMouseMove = useCallback( 184 | ev => { 185 | if (scroll.scrolling) { 186 | ev.preventDefault(); 187 | // ev.stopImmediatePropagation(); 188 | ev.stopPropagation(); 189 | const touch = typeof ev.touches !== "undefined"; 190 | let movementX, movementY; 191 | if (touch) { 192 | const screenX = ev.touches[0].screenX; 193 | const screenY = ev.touches[0].screenY; 194 | movementX = mousePos.x - screenX; 195 | movementY = mousePos.y - screenY; 196 | mousePos.lastX = screenX; 197 | mousePos.lastY = screenY; 198 | } else { 199 | movementX = ev.movementX; 200 | movementY = ev.movementY; 201 | } 202 | 203 | const horizontal = chartScrollContainerHorizontalRef.current; 204 | const vertical = chartScrollContainerVerticalRef.current; 205 | if (horizontal && vertical) { 206 | let x = 0, 207 | y = 0; 208 | if (touch) { 209 | x = 210 | mousePos.currentX + 211 | movementX * options.scroll.dragXMoveMultiplier; 212 | } else { 213 | x = 214 | horizontal.scrollLeft - 215 | movementX * options.scroll.dragXMoveMultiplier; 216 | } 217 | horizontal.scrollLeft = x; 218 | if (touch) { 219 | y = 220 | mousePos.currentY + 221 | movementY * options.scroll.dragYMoveMultiplier; 222 | } else { 223 | y = 224 | vertical.scrollTop - 225 | movementY * options.scroll.dragYMoveMultiplier; 226 | } 227 | vertical.scrollTop = y; 228 | } 229 | } 230 | }, 231 | [ 232 | mousePos.currentX, 233 | mousePos.currentY, 234 | mousePos.lastX, 235 | mousePos.lastY, 236 | mousePos.x, 237 | mousePos.y, 238 | options.scroll.dragXMoveMultiplier, 239 | options.scroll.dragYMoveMultiplier, 240 | scroll 241 | ] 242 | ); 243 | 244 | return useMemo( 245 | () => ( 246 |
247 |
254 |
263 |
269 | {options.taskList.display && ( 270 |
279 | 280 |
281 | )} 282 |
294 | 295 |
296 |
297 |
298 |
310 |
317 |
318 |
319 |
331 |
338 |
339 |
340 | ), 341 | [ 342 | allVisibleTasksHeight, 343 | calendar.height, 344 | chartMouseDown, 345 | chartMouseMove, 346 | chartMouseUp, 347 | chartWheel, 348 | clientWidth, 349 | clientHeight, 350 | mouseMove, 351 | mouseUp, 352 | onHorizontalScroll, 353 | onVerticalScroll, 354 | options.calendar.gap, 355 | options.taskList.display, 356 | rowsHeight, 357 | scrollBarHeight, 358 | style, 359 | taskList.finalWidth, 360 | chartWidth 361 | ] 362 | ); 363 | }; 364 | 365 | export default MainView; 366 | -------------------------------------------------------------------------------- /src/components/TaskList/ItemColumn.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import { emitEvent } from "@/GanttElasticEvents"; 3 | import React, { useContext, useMemo } from "react"; 4 | import { Task, TaskListColumnOption } from "../interfaces"; 5 | 6 | export interface TaskListItemProps { 7 | task: Task; 8 | column: TaskListColumnOption; 9 | } 10 | 11 | const ItemColumn: React.FC = ({ 12 | task, 13 | column, 14 | children 15 | }) => { 16 | const { style } = useContext(GanttElasticContext); 17 | 18 | return useMemo(() => { 19 | const itemColumnStyle = { 20 | ...style["task-list-item-column"], 21 | ...column.style["task-list-item-column"], 22 | width: column.finalWidth + "px", 23 | height: column.height + "px" 24 | }; 25 | const wrapperStyle = { 26 | ...style["task-list-item-value-wrapper"], 27 | ...column.style["task-list-item-value-wrapper"] 28 | }; 29 | 30 | const containerStyle = { 31 | ...style["task-list-item-value-container"], 32 | ...column.style["task-list-item-value-container"] 33 | }; 34 | const valueStyle = { 35 | ...style["task-list-item-value"], 36 | ...column.style["task-list-item-value"] 37 | }; 38 | 39 | /** 40 | * Emit event 41 | * 42 | * @param {Event} event 43 | */ 44 | const onEmitEvent = (event: React.MouseEvent | React.TouchEvent): void => { 45 | const eventName = event.type; 46 | if ( 47 | typeof column.events !== "undefined" && 48 | typeof column.events[eventName] === "function" 49 | ) { 50 | column.events[eventName]({ 51 | event, 52 | data: task, 53 | column: column 54 | }); 55 | } 56 | emitEvent.emit(`taskList-${task.type}-${eventName}`, { 57 | event, 58 | data: task, 59 | column: column 60 | }); 61 | }; 62 | 63 | return ( 64 |
68 |
72 | {children} 73 |
77 |
92 | {typeof column.value === "function" 93 | ? column.value(task) 94 | : task[column.value]} 95 |
96 |
97 |
98 |
99 | ); 100 | }, [style, column, children, task]); 101 | }; 102 | 103 | export default ItemColumn; 104 | -------------------------------------------------------------------------------- /src/components/TaskList/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import _ from "lodash"; 3 | import React, { useContext, useEffect, useMemo, useRef } from "react"; 4 | import TaskListHeader from "./TaskListHeader"; 5 | import TaskListItem from "./TaskListItem"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 8 | export interface TaskListProps {} 9 | 10 | const TaskList: React.FC = () => { 11 | const { style, visibleTasks, rowsHeight, refs } = useContext( 12 | GanttElasticContext 13 | ); 14 | 15 | /** 16 | * refs 17 | */ 18 | const taskListWrapperRef = useRef(null); 19 | const taskListRef = useRef(null); 20 | const taskListItemsRef = useRef(null); 21 | 22 | useEffect(() => { 23 | refs.taskListWrapper = taskListWrapperRef; 24 | refs.taskList = taskListRef; 25 | refs.taskListItems = taskListItemsRef; 26 | }, [refs]); 27 | 28 | return useMemo( 29 | () => ( 30 |
39 |
46 | 47 |
55 | {_.map(visibleTasks, task => ( 56 | 57 | ))} 58 |
59 |
60 |
61 | ), 62 | [rowsHeight, style, visibleTasks] 63 | ); 64 | }; 65 | 66 | export default TaskList; 67 | -------------------------------------------------------------------------------- /src/components/TaskList/TaskListHeader.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import { emitEvent } from "@/GanttElasticEvents"; 3 | import _ from "lodash"; 4 | import React, { 5 | useCallback, 6 | useContext, 7 | useEffect, 8 | useMemo, 9 | useState 10 | } from "react"; 11 | import Expander from "../Expander"; 12 | import { TaskListColumnOption } from "../interfaces"; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 15 | export interface TaskListHeaderProps {} 16 | 17 | const TaskListHeader: React.FC = () => { 18 | const { style, taskList, allTasks, calendar, options } = useContext( 19 | GanttElasticContext 20 | ); 21 | 22 | const [resizer] = useState<{ 23 | moving?: TaskListColumnOption; 24 | x: number; 25 | initialWidth: number; 26 | }>({ 27 | // moving: false, 28 | x: 0, 29 | initialWidth: 0 30 | }); 31 | 32 | /** 33 | * Resizer mouse down event handler 34 | */ 35 | const resizerMouseDown = useCallback( 36 | column => { 37 | return (event: React.MouseEvent): void => { 38 | if (!resizer.moving) { 39 | resizer.moving = column; 40 | resizer.x = event.clientX; 41 | resizer.initialWidth = column.width; 42 | emitEvent.emit("taskList-column-width-change-start", resizer.moving); 43 | } 44 | }; 45 | }, 46 | [resizer] 47 | ); 48 | 49 | /** 50 | * Resizer mouse move event handler 51 | */ 52 | const resizerMouseMove = useCallback( 53 | event => { 54 | if (resizer.moving) { 55 | const lastWidth = resizer.moving.width; 56 | resizer.moving.width = resizer.initialWidth + event.clientX - resizer.x; 57 | if (resizer.moving.width < options.taskList.minWidth) { 58 | resizer.moving.width = options.taskList.minWidth; 59 | } 60 | if (lastWidth !== resizer.moving.width) { 61 | // set({ ...resizer }); 62 | // dispatch({ 63 | // payload: { 64 | // options: { 65 | // ...options, 66 | // taskList: { ...taskList, columns: [...taskList.columns] } 67 | // } 68 | // } 69 | // }); 70 | emitEvent.emit("taskList-column-width-change", resizer.moving); 71 | } 72 | } 73 | }, 74 | [resizer.initialWidth, resizer.moving, resizer.x, options.taskList.minWidth] 75 | ); 76 | 77 | /** 78 | * Resizer mouse up event handler 79 | */ 80 | const resizerMouseUp = useCallback((): void => { 81 | if (resizer.moving) { 82 | resizer.moving = undefined; 83 | emitEvent.emit("taskList-column-width-change-stop", resizer.moving); 84 | } 85 | }, [resizer]); 86 | 87 | useEffect(() => { 88 | document.addEventListener("mouseup", resizerMouseUp); 89 | document.addEventListener("mousemove", resizerMouseMove); 90 | emitEvent.on("main-view-mousemove", resizerMouseMove); 91 | emitEvent.on("main-view-mouseup", resizerMouseUp); 92 | return (): void => { 93 | document.removeEventListener("mouseup", resizerMouseUp); 94 | document.removeEventListener("mousemove", resizerMouseMove); 95 | emitEvent.removeAllListeners("main-view-mousemove"); 96 | emitEvent.removeAllListeners("main-view-mouseup"); 97 | }; 98 | }, [resizerMouseMove, resizerMouseUp]); 99 | 100 | return useMemo( 101 | () => ( 102 |
110 | {_.map(taskList.columns, column => { 111 | return ( 112 |
121 | {column.expander && ( 122 | task.allChildren.length > 0 127 | )} 128 | options={options.taskList.expander} 129 | > 130 | )} 131 |
139 | {column.label} 140 |
141 |
149 |
156 |
163 |
170 |
177 |
178 |
179 |
180 | ); 181 | })} 182 |
183 | ), 184 | [ 185 | allTasks, 186 | calendar.height, 187 | options.calendar.gap, 188 | options.taskList.expander, 189 | resizerMouseDown, 190 | resizerMouseUp, 191 | style, 192 | taskList.columns 193 | ] 194 | ); 195 | }; 196 | 197 | export default TaskListHeader; 198 | -------------------------------------------------------------------------------- /src/components/TaskList/TaskListItem.tsx: -------------------------------------------------------------------------------- 1 | import GanttElasticContext from "@/GanttElasticContext"; 2 | import _ from "lodash"; 3 | import React, { useContext, useMemo } from "react"; 4 | import Expander from "../Expander"; 5 | import { Task } from "../interfaces"; 6 | import ItemColumn from "./ItemColumn"; 7 | 8 | export interface TaskListItemProps { 9 | task: Task; 10 | } 11 | 12 | const TaskListItem: React.FC = ({ task }) => { 13 | const { style, options, taskList } = useContext(GanttElasticContext); 14 | 15 | return useMemo(() => { 16 | const tasks = [task]; 17 | return ( 18 |
24 | {_.map(taskList.columns, (column, index) => ( 25 | 30 | {column.expander && ( 31 | 36 | )} 37 | 38 | ))} 39 |
40 | ); 41 | }, [options.taskList.expander, style, task, taskList.columns]); 42 | }; 43 | 44 | export default TaskListItem; 45 | -------------------------------------------------------------------------------- /src/components/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "@/types"; 2 | 3 | export interface CalendarRowItems { 4 | key: string; 5 | children: Array; 6 | } 7 | 8 | export interface CalendarRowText { 9 | index: number; 10 | key: string; 11 | x: number; 12 | y: number; 13 | width: number; 14 | textWidth: number; 15 | choosenFormatName?: string; 16 | height: number; 17 | label: string; 18 | } 19 | 20 | export interface TaskMap { 21 | [key: string]: Task; 22 | } 23 | 24 | export interface RootTask { 25 | id: null; 26 | label: "root"; 27 | children: []; 28 | allChildren: []; 29 | parents: []; 30 | parent: null; 31 | dependentOn: []; 32 | parentId: null; 33 | __root: boolean; 34 | } 35 | 36 | export interface Task { 37 | id: React.ReactText; // * 38 | dependencyLines: Array<{ points?: string; taskId: React.ReactText }>; 39 | dependentOn: Array; 40 | parentId: React.ReactText | null; 41 | start: dayjs.ConfigType; // * 42 | startTime: number; 43 | end: dayjs.ConfigType; 44 | endTime: number; 45 | label: string; // * 46 | duration: number; // * 47 | progress: number; // * 48 | type: string; 49 | style: DynamicStyle; 50 | collapsed: boolean; 51 | // extends 52 | // parent: Task | null | string | number; 53 | // parents: Task[] | string[] | number[]; 54 | // allChildren: Task[] | string[] | number[]; 55 | // children: Task[] | string[] | number[]; 56 | parent: React.ReactText | null; 57 | parents: Array; 58 | allChildren: Array; 59 | children: Array; 60 | mouseOver: boolean; 61 | height: number; 62 | width: number; 63 | y: number; 64 | x: number; 65 | __root?: boolean; 66 | events?: { 67 | [key: string]: ( 68 | event: React.MouseEvent | React.TouchEvent, 69 | task: Task 70 | ) => void; 71 | }; 72 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 73 | [key: string]: any; 74 | } 75 | 76 | export interface GanttElasticRefs { 77 | taskListWrapper?: React.RefObject; 78 | chartGraphSvg?: React.RefObject; 79 | taskList?: React.RefObject; 80 | svgChart?: React.RefObject; 81 | taskListItems?: React.RefObject; 82 | chartGraph?: React.RefObject; 83 | chartCalendarContainer?: React.RefObject; 84 | chartGraphContainer?: React.RefObject; 85 | chartScrollContainerVertical?: React.RefObject; 86 | chartScrollContainerHorizontal?: React.RefObject; 87 | mainView?: React.RefObject; 88 | chartContainer?: React.RefObject; 89 | chart?: React.RefObject; 90 | } 91 | 92 | export interface ScrollOption { 93 | scrolling: boolean; 94 | top: number; 95 | left: number; 96 | taskList: { 97 | left: number; 98 | right: number; 99 | top: number; 100 | bottom: number; 101 | }; 102 | chart: { 103 | left: number; 104 | right: number; 105 | percent: number; 106 | timePercent: number; 107 | top: number; 108 | bottom: number; 109 | time: number; 110 | timeCenter: number; 111 | dateTime: { 112 | left: number; 113 | right: number; 114 | }; 115 | }; 116 | } 117 | 118 | export interface TimeOption { 119 | firstTime: number; 120 | lastTime: number; 121 | totalViewDurationMs: number; 122 | totalViewDurationPx: number; 123 | steps: StepOption[]; 124 | } 125 | 126 | export interface StepOption { 127 | time: number; 128 | offset: { ms: number; px: number }; 129 | width: { ms: number; px: number }; 130 | } 131 | export interface TaskListOption { 132 | columns: TaskListColumnOption[]; 133 | width: number; 134 | finalWidth: number; 135 | widthFromPercentage: number; 136 | } 137 | 138 | export interface TaskListColumnOption { 139 | id: number; 140 | label: string; 141 | value: string | Function; 142 | width: number; 143 | height: number; 144 | style: DynamicStyle; 145 | finalWidth: number; 146 | thresholdPercent: number; 147 | widthFromPercentage: number; 148 | events?: { 149 | [key: string]: (a: { 150 | event: React.MouseEvent | React.TouchEvent; 151 | data: Task; 152 | column: TaskListColumnOption; 153 | }) => void; 154 | }; 155 | expander: boolean; 156 | _key: string; 157 | } 158 | 159 | export interface CalendarItemWidths { 160 | short?: number; 161 | medium?: number; 162 | long?: number; 163 | [key: string]: number; 164 | } 165 | 166 | export interface Formatted { 167 | long: string[]; 168 | medium: string[]; 169 | short: string[]; 170 | [key: string]: string[]; 171 | } 172 | 173 | export interface CalendarOption { 174 | height: number; 175 | hour: { 176 | widths: CalendarItemWidths[]; 177 | maxWidths: CalendarItemWidths; 178 | formatted: Formatted; 179 | }; 180 | day: { 181 | widths: CalendarItemWidths[]; 182 | maxWidths: CalendarItemWidths; 183 | }; 184 | month: { 185 | widths: CalendarItemWidths[]; 186 | maxWidths: CalendarItemWidths; 187 | }; 188 | } 189 | export interface State { 190 | // width: number; 191 | // height: number; 192 | // clientWidth: number; 193 | // outerHeight: number; 194 | // rowsHeight: number; 195 | // scrollBarHeight: number; 196 | // allVisibleTasksHeight: number; 197 | scroll: ScrollOption; 198 | times: TimeOption; 199 | taskList: TaskListOption; 200 | calendar: CalendarOption; 201 | } 202 | 203 | export interface Options { 204 | scroll: { 205 | dragXMoveMultiplier: number; //* 206 | dragYMoveMultiplier: number; //* 207 | }; 208 | scope: { 209 | //* 210 | before: number; 211 | after: number; 212 | }; 213 | times: { 214 | timeScale: number; 215 | timeZoom: number; //* 216 | firstTime: number; 217 | lastTime: number; 218 | timePerPixel: number; // 计算值,设置无效 219 | stepDuration: dayjs.UnitType; 220 | }; 221 | row: { 222 | height: number; //* 223 | }; 224 | maxRows: number; //* 225 | maxHeight: number; //* 226 | chart: { 227 | grid: { 228 | horizontal: { 229 | gap: number; //* 230 | }; 231 | }; 232 | progress: { 233 | width: number; //* 234 | height: number; //* 235 | pattern: boolean; 236 | bar: boolean; 237 | }; 238 | text: { 239 | offset: number; //* 240 | xPadding: number; //* 241 | display: boolean; //* 242 | }; 243 | expander: { 244 | type: string; 245 | display: boolean; //* 246 | displayIfTaskListHidden: boolean; //* 247 | offset: number; //* 248 | size: number; 249 | }; 250 | }; 251 | taskList: { 252 | display: boolean; //* 253 | resizeAfterThreshold: boolean; //* 254 | widthThreshold: number; //* 255 | columns: GanttElasticTaskListColumn[]; 256 | percent: number; //* 257 | minWidth: number; 258 | expander: { 259 | type: string; 260 | size: number; 261 | columnWidth: number; 262 | padding: number; 263 | margin: number; 264 | straight: boolean; 265 | }; 266 | }; 267 | calendar: { 268 | workingDays: number[]; //* 269 | gap: number; //* 270 | strokeWidth: number; 271 | hour: { 272 | height: number; //* 273 | display: boolean; //* 274 | format: DateFormat; //* 275 | }; 276 | day: { 277 | height: number; //* 278 | display: boolean; //* 279 | format: DateFormat; //* 280 | }; 281 | month: { 282 | height: number; //* 283 | display: boolean; //* 284 | format: DateFormat; //* 285 | }; 286 | }; 287 | locale: ILocale; 288 | } 289 | -------------------------------------------------------------------------------- /src/components/utils/charts.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import _ from "lodash"; 3 | import { StepOption } from "../interfaces"; 4 | 5 | /** 6 | * Determine if element is inside current view port 7 | * 8 | * @param left scroll.chart.left 9 | * @param right scroll.chart.right 10 | * @param x step.offset.px 11 | * @param width 12 | * @param buffer 13 | */ 14 | const isInsideViewPort = ( 15 | left: number, 16 | right: number, 17 | x: number, 18 | width: number, 19 | buffer = 5000 20 | ): boolean => { 21 | return ( 22 | (x + width + buffer >= left && x - buffer <= right) || 23 | (x - buffer <= left && x + width + buffer >= right) 24 | ); 25 | }; 26 | 27 | /** 28 | * Get working days 29 | * 30 | * @param steps options.times.steps 31 | * @param workingDays options.calendar.workingDays 32 | */ 33 | const workingDays = ( 34 | steps: StepOption[], 35 | workingDays: number[] 36 | ): StepOption[] => { 37 | return _.filter(steps, step => { 38 | return !workingDays.includes(dayjs(step.time).day()); 39 | }); 40 | }; 41 | 42 | /** 43 | * Convert time (in milliseconds) to pixel offset inside chart 44 | * 45 | * @param ms 46 | * @param firstTime options.times.firstTime 47 | * @param timePerPixel options.times.timePerPixel 48 | */ 49 | const timeToPixelOffsetX = ( 50 | ms: number, 51 | firstTime: number, 52 | timePerPixel: number 53 | ): number => { 54 | let x = ms - firstTime; 55 | if (x) { 56 | x = x / timePerPixel; 57 | } 58 | return x; 59 | }; 60 | 61 | export { isInsideViewPort, workingDays, timeToPixelOffsetX }; 62 | -------------------------------------------------------------------------------- /src/components/utils/columns.ts: -------------------------------------------------------------------------------- 1 | import { GanttElasticTaskListColumn } from "@/types"; 2 | import _ from "lodash"; 3 | import { Task, TaskListColumnOption } from "../interfaces"; 4 | import { getMaximalExpanderWidth, getTaskHeight } from "./tasks"; 5 | 6 | /** 7 | * Initialize columns 8 | * 9 | */ 10 | const initialzeColumns = ( 11 | columns: GanttElasticTaskListColumn[] 12 | ): TaskListColumnOption[] => { 13 | return _.map(columns, (column, index) => { 14 | return { 15 | ...column, 16 | height: 0, 17 | finalWidth: 0, 18 | thresholdPercent: 100, 19 | widthFromPercentage: 0, 20 | expander: !!column.expander, 21 | style: column.style ?? {}, 22 | _key: `${index}-${column.label}` 23 | }; 24 | }); 25 | }; 26 | 27 | /** 28 | * Calculate task list columns dimensions 29 | * 30 | * @param columns 31 | * @param tasks 32 | * @param percent options.taskList.percent 33 | * @param padding options.taskList.expander.padding 34 | * @param margin options.taskList.expander.margin 35 | * @param height options.row.height 36 | * @param gap options.chart.grid.horizontal.gap 37 | * @param strokeWidth style["grid-line-horizontal"]["strokeWidth"] 38 | */ 39 | const calculateTaskListColumnsDimensions = ( 40 | columns: TaskListColumnOption[], 41 | tasks: Task[], 42 | percent: number, 43 | padding: number, 44 | margin: number, 45 | height: number, 46 | gap: number, 47 | strokeWidth: number 48 | ): { 49 | columns: TaskListColumnOption[]; 50 | widthFromPercentage: number; 51 | finalWidth: number; 52 | width: number; 53 | } => { 54 | let final = 0; 55 | let percentage = 0; 56 | let totalWidth = 0; 57 | for (const column of columns) { 58 | if (column.expander) { 59 | column.widthFromPercentage = 60 | ((getMaximalExpanderWidth(tasks, padding, margin) + column.width) / 61 | 100) * 62 | percent; 63 | } else { 64 | column.widthFromPercentage = (column.width / 100) * percent; 65 | } 66 | percentage += column.widthFromPercentage; 67 | column.finalWidth = 68 | (column.thresholdPercent * column.widthFromPercentage) / 100; 69 | final += column.finalWidth; 70 | column.height = getTaskHeight(height, gap, strokeWidth) - strokeWidth; 71 | totalWidth += column.width; 72 | } 73 | 74 | return { 75 | columns, 76 | widthFromPercentage: percentage, 77 | finalWidth: final, 78 | width: totalWidth 79 | }; 80 | }; 81 | 82 | export { calculateTaskListColumnsDimensions, initialzeColumns }; 83 | -------------------------------------------------------------------------------- /src/components/utils/options.ts: -------------------------------------------------------------------------------- 1 | import { GanttElasticOptions } from "@/types"; 2 | import dayjs from "dayjs"; 3 | import locale from "dayjs/locale/zh-cn"; 4 | import { Options, State } from "../interfaces"; 5 | 6 | const defaultOptions: Options = { 7 | // width: 0, 8 | // height: 0, 9 | // clientWidth: 0, 10 | // outerHeight: 0, 11 | // rowsHeight: 0, 12 | // scrollBarHeight: 0, 13 | // allVisibleTasksHeight: 0, 14 | scroll: { 15 | dragXMoveMultiplier: 3, //* 16 | dragYMoveMultiplier: 2 //* 17 | }, 18 | scope: { 19 | //* 20 | before: 1, 21 | after: 1 22 | }, 23 | times: { 24 | firstTime: 0, 25 | lastTime: 0, 26 | timeScale: 60 * 1000, 27 | timeZoom: 17, //* 28 | timePerPixel: 0, 29 | stepDuration: "day" 30 | }, 31 | row: { 32 | height: 24 //* 33 | }, 34 | maxRows: 20, //* 35 | maxHeight: 0, //* 36 | chart: { 37 | grid: { 38 | horizontal: { 39 | gap: 6 //* 40 | } 41 | }, 42 | progress: { 43 | width: 20, //* 44 | height: 6, //* 45 | pattern: true, 46 | bar: false 47 | }, 48 | text: { 49 | offset: 4, //* 50 | xPadding: 10, //* 51 | display: true //* 52 | }, 53 | expander: { 54 | type: "chart", 55 | display: false, //* 56 | displayIfTaskListHidden: true, //* 57 | offset: 4, //* 58 | size: 18 59 | } 60 | }, 61 | taskList: { 62 | display: true, //* 63 | resizeAfterThreshold: true, //* 64 | widthThreshold: 75, //* 65 | columns: [ 66 | { 67 | id: 0, 68 | label: "ID", 69 | value: "value", 70 | width: 40, 71 | style: {} 72 | }, 73 | { 74 | id: 1, 75 | label: "Task Name", 76 | value: "value", 77 | width: 200, 78 | style: {} 79 | } 80 | ], 81 | percent: 100, //* 82 | minWidth: 18, 83 | expander: { 84 | type: "task-list", 85 | size: 16, 86 | columnWidth: 24, 87 | padding: 16, 88 | margin: 10, 89 | straight: false 90 | } 91 | }, 92 | calendar: { 93 | workingDays: [1, 2, 3, 4, 5], //* 94 | gap: 6, //* 95 | strokeWidth: 1, 96 | hour: { 97 | height: 20, //* 98 | display: true, //* 99 | format: { 100 | //* 101 | long(date: dayjs.Dayjs): string { 102 | return date.format("HH:mm"); 103 | }, 104 | medium(date: dayjs.Dayjs): string { 105 | return date.format("HH:mm"); 106 | }, 107 | short(date: dayjs.Dayjs): string { 108 | return date.format("HH"); 109 | } 110 | } 111 | }, 112 | day: { 113 | height: 20, //* 114 | display: true, //* 115 | format: { 116 | long(date: dayjs.Dayjs): string { 117 | return date.format("DD dddd"); 118 | }, 119 | medium(date: dayjs.Dayjs): string { 120 | return date.format("DD ddd"); 121 | }, 122 | short(date: dayjs.Dayjs): string { 123 | return date.format("DD"); 124 | } 125 | } 126 | }, 127 | month: { 128 | height: 20, //* 129 | display: true, //* 130 | format: { 131 | //* 132 | short(date: dayjs.Dayjs): string { 133 | return date.format("MM"); 134 | }, 135 | medium(date: dayjs.Dayjs): string { 136 | return date.format("MMM 'YY"); 137 | }, 138 | long(date: dayjs.Dayjs): string { 139 | return date.format("MMMM YYYY"); 140 | } 141 | } 142 | } 143 | }, 144 | locale 145 | }; 146 | 147 | /** 148 | * Helper function to fill out empty options in user settings 149 | * 150 | * @param {object} userOptions - initial user options that will merge with those below 151 | * @returns {object} merged options 152 | */ 153 | const getOptions = (userOptions?: Partial): Options => { 154 | let localeName = "en"; 155 | if ( 156 | userOptions && 157 | typeof userOptions.locale !== "undefined" && 158 | typeof userOptions.locale.name !== "undefined" 159 | ) { 160 | localeName = userOptions.locale.name; 161 | } 162 | return { 163 | ...defaultOptions 164 | }; 165 | }; 166 | 167 | const defaultState: State = { 168 | width: 0, 169 | // height: 0, 170 | // clientWidth: 0, 171 | // outerHeight: 0, 172 | // rowsHeight: 0, 173 | // scrollBarHeight: 0, 174 | // allVisibleTasksHeight: 0, 175 | 176 | scroll: { 177 | scrolling: false, 178 | top: 0, 179 | left: 0, 180 | taskList: { 181 | left: 0, 182 | right: 0, 183 | top: 0, 184 | bottom: 0 185 | }, 186 | chart: { 187 | left: 0, 188 | right: 0, 189 | percent: 0, 190 | timePercent: 0, 191 | top: 0, 192 | bottom: 0, 193 | time: 0, 194 | timeCenter: 0, 195 | dateTime: { 196 | left: 0, 197 | right: 0 198 | } 199 | } 200 | }, 201 | times: { 202 | firstTime: 0, 203 | lastTime: 0, 204 | totalViewDurationMs: 0, 205 | totalViewDurationPx: 0, 206 | steps: [] 207 | }, 208 | taskList: { 209 | columns: [], 210 | width: 0, 211 | finalWidth: 0, 212 | widthFromPercentage: 0 213 | }, 214 | calendar: { 215 | height: 0, 216 | hour: { 217 | widths: [], 218 | maxWidths: { short: 0, medium: 0, long: 0 }, 219 | formatted: { 220 | long: [], 221 | medium: [], 222 | short: [] 223 | } 224 | }, 225 | day: { 226 | widths: [], 227 | maxWidths: { short: 0, medium: 0, long: 0 } 228 | }, 229 | month: { 230 | widths: [], 231 | maxWidths: { short: 0, medium: 0, long: 0 } 232 | } 233 | } 234 | }; 235 | 236 | export { getOptions, defaultState, defaultOptions }; 237 | -------------------------------------------------------------------------------- /src/components/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStyle } from "@/types"; 2 | 3 | const getStyle = ( 4 | fontSize = "12px", 5 | fontFamily = "Arial, sans-serif" 6 | ): DynamicStyle => { 7 | return { 8 | "main-view": { 9 | background: "#FFFFFF" 10 | }, 11 | "main-container-wrapper": { 12 | overflow: "hidden", 13 | borderTop: "1px solid #eee", 14 | borderBottom: "1px solid #eee" 15 | }, 16 | "main-container": { 17 | float: "left", 18 | maxWidth: "100%" 19 | }, 20 | "main-view-container": {}, 21 | container: { 22 | display: "flex", 23 | maxWidth: "100%", 24 | height: "100%" 25 | }, 26 | "calendar-wrapper": { 27 | userSelect: "none" 28 | }, 29 | calendar: { 30 | width: "100%", 31 | background: "#f3f5f7", 32 | display: "block" 33 | }, 34 | "calendar-row": { 35 | display: "flex", 36 | justifyContent: "space-evenly" 37 | }, 38 | "calendar-row--month": {}, 39 | "calendar-row--day": {}, 40 | "calendar-row--hour": { 41 | borderBottom: "1px solid #eee" 42 | }, 43 | "calendar-row-rect": { 44 | background: "transparent", 45 | display: "flex" 46 | }, 47 | "calendar-row-rect--month": {}, 48 | "calendar-row-rect--day": {}, 49 | "calendar-row-rect--hour": {}, 50 | "calendar-row-rect-child": { 51 | display: "block", 52 | borderRightWidth: "1px", // Calendar 53 | borderRightColor: "#dadada", 54 | borderRightStyle: "solid", 55 | position: "relative" 56 | }, 57 | "calendar-row-rect-child--month": {}, 58 | "calendar-row-rect-child--day": { textAlign: "center" }, 59 | "calendar-row-rect-child--hour": { textAlign: "center" }, 60 | "calendar-row-text": { 61 | fontFamily, // GanttElastic 62 | fontSize, //GanttElastic 63 | color: "#606060", 64 | display: "inline-block", 65 | position: "relative" 66 | }, 67 | "calendar-row-text--month": {}, 68 | "calendar-row-text--day": {}, 69 | "calendar-row-text--hour": {}, 70 | "task-list-wrapper": {}, 71 | "task-list": { background: "transparent", borderColor: "#eee" }, 72 | "task-list-header": { 73 | display: "flex", 74 | userSelect: "none", 75 | verticalAlign: "middle", 76 | borderBottom: "1px solid #eee", 77 | borderLeft: "1px solid #eee" 78 | }, 79 | "task-list-header-column": { 80 | borderLeft: "1px solid #00000050", 81 | boxSizing: "border-box", 82 | display: "flex", 83 | background: "#f3f5f7", 84 | borderColor: "transparent" 85 | }, 86 | "task-list-expander-wrapper": { 87 | display: "inline-flex", 88 | flexShrink: 0, 89 | boxSizing: "border-box", 90 | margin: "0 0 0 10px" 91 | }, 92 | "task-list-expander-content": { 93 | display: "inline-flex", 94 | cursor: "pointer", 95 | margin: "auto 0px", 96 | boxSizing: "border-box", 97 | userSelect: "none" 98 | }, 99 | "task-list-expander-line": { 100 | fill: "transparent", 101 | stroke: "#000000", 102 | strokeWidth: 1, 103 | strokeLinecap: "round" 104 | }, 105 | "task-list-expander-border": { 106 | fill: "#ffffffa0", 107 | stroke: "#000000A0" 108 | }, 109 | "chart-expander-wrapper": { 110 | display: "block", 111 | lineHeight: 1, 112 | boxSizing: "border-box", 113 | margin: "0" 114 | }, 115 | "chart-expander-content": { 116 | display: "inline-flex", 117 | cursor: "pointer", 118 | margin: "auto 0px", 119 | boxSizing: "border-box", 120 | userSelect: "none" 121 | }, 122 | "chart-expander-line": { 123 | fill: "transparent", 124 | stroke: "#000000", 125 | strokeWidth: 1, 126 | strokeLinecap: "round" 127 | }, 128 | "chart-expander-border": { 129 | fill: "#ffffffa0", 130 | stroke: "#000000A0" 131 | }, 132 | "task-list-container": {}, 133 | "task-list-header-label": { 134 | overflow: "hidden", 135 | textOverflow: "ellipsis", 136 | fontFamily, 137 | fontSize, 138 | boxSizing: "border-box", 139 | margin: "auto 6px", 140 | flexGrow: 1, 141 | verticalAlign: "middle" 142 | }, 143 | "task-list-header-resizer-wrapper": { 144 | background: "transparent", 145 | height: "100%", 146 | width: "6px", 147 | cursor: "col-resize", 148 | display: "inline-flex", 149 | verticalAlign: "center" 150 | }, 151 | "task-list-header-resizer": { margin: "auto 0px" }, 152 | "task-list-header-resizer-dot": { 153 | width: "3px", 154 | height: "3px", 155 | background: "#ddd", 156 | borderRadius: "100%", 157 | margin: "4px 0px" 158 | }, 159 | "task-list-items": { 160 | overflow: "hidden" 161 | }, 162 | "task-list-item": { 163 | borderTop: "1px solid #eee", 164 | borderRight: "1px solid #eee", 165 | boxSizing: "border-box", 166 | display: "flex", 167 | background: "transparent" 168 | }, 169 | "task-list-item-column": { 170 | display: "inline-flex", 171 | flexShrink: 0, 172 | borderLeft: "1px solid #00000050", 173 | boxSizing: "border-box", 174 | borderColor: "#eee" 175 | }, 176 | "task-list-item-value-wrapper": { 177 | overflow: "hidden", 178 | display: "flex", 179 | width: "100%" 180 | }, 181 | "task-list-item-value-container": { 182 | margin: "auto 0px", 183 | overflow: "hidden" 184 | }, 185 | "task-list-item-value": { 186 | display: "block", 187 | flexShrink: 100, 188 | fontFamily, 189 | fontSize, 190 | marginTop: "auto", 191 | marginBottom: "auto", 192 | marginLeft: "6px", // TaskList 193 | marginRight: "6px", 194 | overflow: "hidden", 195 | textOverflow: "ellipsis", 196 | lineHeight: "1.5em", 197 | wordBreak: "keep-all", 198 | whiteSpace: "nowrap", 199 | color: "#606060", 200 | background: "#FFFFFF" 201 | }, 202 | "grid-lines": {}, 203 | "grid-line-horizontal": { 204 | stroke: "#00000010", 205 | strokeWidth: 1 206 | }, 207 | "grid-line-vertical": { 208 | stroke: "#00000010", 209 | strokeWidth: 1 210 | }, 211 | "grid-line-time": { 212 | stroke: "#FF000080", 213 | strokeWidth: 1 214 | }, 215 | chart: { 216 | userSelect: "none", 217 | overflow: "hidden" 218 | }, 219 | "chart-calendar-container": { 220 | userSelect: "none", 221 | overflow: "hidden", 222 | maxWidth: "100%", 223 | borderRight: "1px solid #eee" 224 | }, 225 | "chart-graph-container": { 226 | userSelect: "none", 227 | overflow: "hidden", 228 | maxWidth: "100%", 229 | borderRight: "1px solid #eee" 230 | }, 231 | "chart-area": {}, 232 | "chart-graph": { 233 | overflow: "hidden" 234 | }, 235 | "chart-row-text-wrapper": {}, 236 | "chart-row-text": { 237 | background: "#ffffffa0", 238 | borderRadius: "10px", 239 | fontFamily, 240 | fontSize, 241 | fontWeight: "normal", 242 | color: "#000000a0", 243 | height: "100%", 244 | display: "inline-block" 245 | }, 246 | "chart-row-text-content": { 247 | padding: "0px 6px" 248 | }, 249 | "chart-row-text-content--text": {}, 250 | "chart-row-text-content--html": {}, 251 | "chart-row-wrapper": {}, 252 | "chart-row-bar-wrapper": {}, 253 | "chart-row-bar": {}, 254 | "chart-row-bar-polygon": { 255 | stroke: "#E74C3C", 256 | strokeWidth: 1, 257 | fill: "#F75C4C" 258 | }, 259 | "chart-row-project-wrapper": {}, 260 | "chart-row-project": {}, 261 | "chart-row-project-polygon": {}, 262 | "chart-row-milestone-wrapper": {}, 263 | "chart-row-milestone": {}, 264 | "chart-row-milestone-polygon": {}, 265 | "chart-row-task-wrapper": {}, 266 | "chart-row-task": {}, 267 | "chart-row-task-polygon": {}, 268 | "chart-row-progress-bar-wrapper": {}, 269 | "chart-row-progress-bar": {}, 270 | "chart-row-progress-bar-line": { 271 | stroke: "#ffffff25", 272 | strokeWidth: 20 273 | }, 274 | "chart-row-progress-bar-solid": { 275 | fill: "#0EAC51", 276 | height: "20%" 277 | }, 278 | "chart-row-progress-bar-pattern": { 279 | fill: "url(#diagonalHatch)", 280 | transform: "translateY(0.1) scaleY(0.8)" 281 | }, 282 | "chart-row-progress-bar-outline": { 283 | stroke: "#E74C3C", 284 | strokeWidth: 1 285 | }, 286 | "chart-dependency-lines-wrapper": {}, 287 | "chart-dependency-lines-path": { 288 | fill: "transparent", 289 | stroke: "#FFa00090", 290 | strokeWidth: 2 291 | }, 292 | "chart-scroll-container": {}, 293 | "chart-scroll-container--horizontal": { 294 | overflow: "auto", 295 | maxWidth: "100%" 296 | }, 297 | "chart-scroll-container--vertical": { 298 | overflowY: "auto", 299 | overflowX: "hidden", 300 | maxHeight: "100%", 301 | float: "right" 302 | }, 303 | "chart-days-highlight-rect": { 304 | fill: "#f3f5f780" 305 | }, 306 | "slot-header-beforeOptions": { 307 | display: "inline-block" 308 | } 309 | }; 310 | }; 311 | 312 | const prepareStyle = (userStyle?: DynamicStyle): DynamicStyle => { 313 | let fontSize: any = "12px"; 314 | let fontFamily = window 315 | .getComputedStyle(document.body) 316 | .getPropertyValue("font-family") 317 | .toString(); 318 | if (typeof userStyle !== "undefined") { 319 | if (typeof userStyle.fontSize !== "undefined") { 320 | fontSize = userStyle.fontSize; 321 | } 322 | if (typeof userStyle.fontFamily !== "undefined") { 323 | fontFamily = `${userStyle.fontFamily}`; 324 | } 325 | } 326 | // return getStyle(fontSize, fontFamily); 327 | return getStyle(fontSize, fontFamily); 328 | }; 329 | 330 | export { getStyle, prepareStyle }; 331 | -------------------------------------------------------------------------------- /src/components/utils/tasks.ts: -------------------------------------------------------------------------------- 1 | import { GanttElasticTask } from "@/types"; 2 | import dayjs from "dayjs"; 3 | import _ from "lodash"; 4 | import { Task } from "../interfaces"; 5 | 6 | /** 7 | * Fill out empty task properties and make it reactive 8 | */ 9 | const fillTasks = (inTask: Partial): Task => { 10 | const task: Task = { 11 | id: 0, 12 | type: "task", 13 | startTime: 0, 14 | endTime: 0, 15 | start: 0, 16 | label: "task", 17 | progress: 0, 18 | end: 0, 19 | duration: 0, 20 | children: [], 21 | allChildren: [], 22 | parents: [], 23 | parent: null, 24 | dependentOn: [], 25 | dependencyLines: [], 26 | parentId: null, 27 | style: {}, 28 | collapsed: false, 29 | mouseOver: false, 30 | height: 0, 31 | width: 0, 32 | y: 0, 33 | x: 0, 34 | ...inTask 35 | }; 36 | 37 | if (_.isEmpty(task.parentId)) { 38 | task.parentId = 0; 39 | } 40 | if (_.isEmpty(task.startTime)) { 41 | task.startTime = dayjs(task.start).valueOf(); 42 | } 43 | if (_.isEmpty(task.endTime) && task.end) { 44 | task.endTime = dayjs(task.end).valueOf(); 45 | } else if (_.isEmpty(task.endTime) && task.duration) { 46 | task.endTime = task.startTime + task.duration; 47 | } 48 | if (_.isEmpty(task.duration) && task.endTime) { 49 | task.duration = task.endTime - task.startTime; 50 | } 51 | return task; 52 | }; 53 | 54 | /** 55 | * Make task tree, after reset - look above 56 | * 57 | * @param {object} task 58 | * @returns {object} tasks with children and parents 59 | */ 60 | const makeTaskTree = (task: Task, tasks: Task[]): Task => { 61 | for (let i = 0, len = tasks.length; i < len; i++) { 62 | let current = tasks[i]; 63 | // 当taskId是空时,task时rootTask,如果parentId也是空时,则应该时rootTask的子元素 64 | if (current.parentId === task.id) { 65 | if (task.parents.length) { 66 | task.parents.forEach(parent => current.parents.push(parent)); 67 | } 68 | // eslint-disable-next-line no-prototype-builtins 69 | if (!task.propertyIsEnumerable("__root")) { 70 | current.parents.push(task.id); 71 | current.parent = task.id; 72 | } else { 73 | current.parents = []; 74 | current.parent = null; 75 | } 76 | current = makeTaskTree(current, tasks); 77 | task.allChildren.push(current.id); 78 | task.children.push(current.id); 79 | current.allChildren.forEach(childId => task.allChildren.push(childId)); 80 | } 81 | } 82 | return task; 83 | }; 84 | 85 | /** 86 | * Convert time (in milliseconds) to pixel offset inside chart 87 | * 88 | */ 89 | const timeToPixelOffsetX = ( 90 | ms: number, 91 | firstTime: number, 92 | timePerPixel: number 93 | ): number => { 94 | let x = ms - firstTime; 95 | if (x) { 96 | x = x / timePerPixel; 97 | } 98 | return x; 99 | }; 100 | 101 | /** 102 | * recalculate task variables 103 | * 104 | * @param allTasks 105 | * @param firstTime options.times.firstTime, 106 | * @param timePerPixel options.times.timePerPixel 107 | * @param rowHeight options.row.height 108 | * @param horizontalGap options.chart.grid.horizontal.gap 109 | * @param strokeWidth 110 | */ 111 | const recalculateTasks = ( 112 | allTasks: Task[], 113 | firstTime: number, 114 | timePerPixel: number, 115 | rowHeight: number, 116 | horizontalGap: number, 117 | strokeWidth: number 118 | ): Task[] => { 119 | return _.map(allTasks, (task, index) => { 120 | let width = task.duration / timePerPixel - strokeWidth; 121 | if (width < 0) { 122 | width = 0; 123 | } 124 | const x = timeToPixelOffsetX(task.startTime, firstTime, timePerPixel); 125 | const y = (rowHeight + horizontalGap * 2) * index + horizontalGap; 126 | return { 127 | ...task, 128 | width, 129 | height: rowHeight, 130 | x, 131 | y 132 | }; 133 | }); 134 | }; 135 | 136 | /** 137 | * Get maximal level of nested task children 138 | * 139 | * @param tasks 140 | */ 141 | const getMaximalLevel = (tasks: Task[]): number => { 142 | let maximalLevel = 0; 143 | _.forEach(tasks, task => { 144 | if (task.parents.length > maximalLevel) { 145 | maximalLevel = task.parents.length; 146 | } 147 | }); 148 | return maximalLevel - 1; 149 | }; 150 | /** 151 | * Get maximal expander width - to calculate straight task list text 152 | * 153 | * @param tasks 154 | * @param padding options.taskList.expander.padding 155 | * @param margin options.taskList.expander.margin 156 | */ 157 | const getMaximalExpanderWidth = ( 158 | tasks: Task[], 159 | padding: number, 160 | margin: number 161 | ): number => { 162 | return getMaximalLevel(tasks) * padding + margin; 163 | }; 164 | 165 | /** 166 | * Get one task height 167 | * @param height options.row.height 168 | * @param gap options.chart.grid.horizontal.gap 169 | * @param strokeWidth style["grid-line-horizontal"]["strokeWidth"] 170 | * @param withStroke 171 | */ 172 | const getTaskHeight = ( 173 | height: number, 174 | gap: number, 175 | strokeWidth: number, 176 | withStroke = false 177 | ): number => { 178 | if (withStroke) { 179 | return ( 180 | height + gap * 2 + strokeWidth 181 | // parseInt(`${style["grid-line-horizontal"]["strokeWidth"]}`) 182 | ); 183 | } 184 | return height + gap * 2; 185 | }; 186 | 187 | /** 188 | * Get specified tasks height 189 | * 190 | * @param visibleTasks 191 | * @param height options.row.height 192 | * @param gap options.chart.grid.horizontal.gap 193 | * @param strokeWidth style["grid-line-horizontal"]["strokeWidth"] 194 | */ 195 | const getTasksHeight = ( 196 | visibleTasks: Task[], 197 | height: number, 198 | gap: number, 199 | strokeWidth: number 200 | ): number => { 201 | return visibleTasks.length * getTaskHeight(height, gap, strokeWidth); 202 | }; 203 | 204 | /** 205 | * Get gantt total height 206 | * @param visibleTasks 207 | * @param rowHeight options.row.height 208 | * @param gridHorizontalGap options.chart.grid.horizontal.gap 209 | * @param calendarGap options.calendar.gap 210 | * @param calendarStrokeWidth options.calendar.strokeWidth 211 | * @param calendarHeight options.calendar.height 212 | * @param scrollBarHeight options.scrollBarHeight 213 | * @param outer 214 | */ 215 | const getHeight = ( 216 | visibleTasks: Task[], 217 | rowHeight: number, 218 | gridHorizontalGap: number, 219 | calendarGap: number, 220 | calendarStrokeWidth: number, 221 | calendarHeight: number, 222 | scrollBarHeight: number, 223 | outer = false 224 | ): number => { 225 | let height = 226 | visibleTasks.length * (rowHeight + gridHorizontalGap * 2) + 227 | calendarHeight + 228 | calendarStrokeWidth + 229 | calendarGap; 230 | if (outer) { 231 | height += scrollBarHeight; 232 | } 233 | return height; 234 | }; 235 | 236 | export { 237 | recalculateTasks, 238 | getMaximalLevel, 239 | getHeight, 240 | getTasksHeight, 241 | getTaskHeight, 242 | getMaximalExpanderWidth, 243 | fillTasks, 244 | makeTaskTree 245 | }; 246 | -------------------------------------------------------------------------------- /src/components/utils/times.ts: -------------------------------------------------------------------------------- 1 | import { DateFormat, DynamicStyle } from "@/types"; 2 | import dayjs from "dayjs"; 3 | import { 4 | CalendarItemWidths, 5 | CalendarRowItems, 6 | Formatted, 7 | Options, 8 | StepOption 9 | } from "../interfaces"; 10 | 11 | export interface DateCount { 12 | count: number; 13 | type: string; 14 | } 15 | 16 | /** 17 | * RecalculateTimes function's part 18 | * @param totalViewDurationPx options.times.totalViewDurationPx 19 | * @param strokeWidth style["grid-line-vertical"]["strokeWidth"] 20 | * @return options.width 21 | */ 22 | const width: (totalViewDurationPx: number, strokeWidth: number) => number = ( 23 | totalViewDurationPx, 24 | strokeWidth 25 | ) => { 26 | return totalViewDurationPx + strokeWidth; 27 | }; 28 | 29 | /** 30 | * RecalculateTimes function's part 31 | * @param timeScale options.times.timeScale 32 | * @param timeZoom options.times.timeZoom 33 | * 34 | * @return options.times.timePerPixel 35 | */ 36 | const timePerPixel: (timeScale: number, timeZoom: number) => number = ( 37 | timeScale, 38 | timeZoom 39 | ) => { 40 | const max = timeScale * 60; 41 | const min = timeScale; 42 | const steps = max / min; 43 | const percent = timeZoom / 100; 44 | return timeScale * steps * percent + Math.pow(2, timeZoom); 45 | }; 46 | 47 | /** 48 | * RecalculateTimes function's part 49 | * @param firstTime options.times.firstTime 50 | * @param lastTime options.times.lastTime 51 | * 52 | * @return options.times.totalViewDurationMs 53 | */ 54 | const totalViewDurationMs: ( 55 | firstTime: dayjs.ConfigType, 56 | lastTime: dayjs.ConfigType 57 | ) => number = (firstTime, lastTime) => { 58 | if (typeof firstTime === "number" && typeof lastTime === "number") { 59 | return lastTime - firstTime; 60 | } 61 | return dayjs(lastTime).diff(firstTime, "millisecond"); 62 | }; 63 | 64 | /** 65 | * 66 | * RecalculateTimes function's part 67 | * @param totalViewDurationMs options.times.totalViewDurationMs 68 | * @param timePerPixel options.times.timePerPixel 69 | * 70 | * @return options.times.totalViewDurationPx 71 | */ 72 | const totalViewDurationPx: ( 73 | totalViewDurationMs: number, 74 | timePerPixel: number 75 | ) => number = (totalViewDurationMs, timePerPixel) => { 76 | return totalViewDurationMs / timePerPixel; 77 | }; 78 | 79 | /** 80 | * Calculate steps 81 | * Steps are days by default 82 | * Each step contain information about time offset and pixel offset of this time inside gantt chart 83 | * 84 | */ 85 | const calculateSteps: ( 86 | firstTime: number, 87 | lastTime: number, 88 | timePerPixel: number, 89 | totalViewDurationMs: number, 90 | totalViewDurationPx: number, 91 | stepDuration: dayjs.UnitType 92 | ) => StepOption[] = ( 93 | firstTime, 94 | lastTime, 95 | timePerPixel = 0, 96 | totalViewDurationMs = 0, 97 | totalViewDurationPx = 0, 98 | stepDuration = "day" 99 | ) => { 100 | const steps: StepOption[] = []; 101 | 102 | steps.push({ 103 | time: firstTime.valueOf(), 104 | offset: { 105 | ms: 0, 106 | px: 0 107 | }, 108 | width: { 109 | ms: 0, 110 | px: 0 111 | } 112 | }); 113 | for ( 114 | let currentDate = dayjs(firstTime) 115 | .add(1, stepDuration) 116 | .startOf("day"); 117 | currentDate.valueOf() <= lastTime; 118 | currentDate = currentDate.add(1, stepDuration).startOf("day") 119 | ) { 120 | const offsetMs = currentDate.diff(firstTime, "millisecond"); 121 | const offsetPx = offsetMs / timePerPixel; 122 | const step = { 123 | time: currentDate.valueOf(), 124 | offset: { 125 | ms: offsetMs, 126 | px: offsetPx 127 | }, 128 | width: { ms: 0, px: 0 } 129 | }; 130 | const previousStep = steps[steps.length - 1]; 131 | previousStep.width = { 132 | ms: offsetMs - previousStep.offset.ms, 133 | px: offsetPx - previousStep.offset.px 134 | }; 135 | steps.push(step); 136 | } 137 | const lastStep = steps[steps.length - 1]; 138 | lastStep.width = { 139 | ms: totalViewDurationMs - lastStep.offset.ms, 140 | px: totalViewDurationPx - lastStep.offset.px 141 | }; 142 | return steps; 143 | }; 144 | 145 | /** 146 | * Compute width of calendar hours column widths basing on text widths 147 | * @param hourFormats options.calendar.hour.format 148 | * @param localeName options.locale.name 149 | * @param style 150 | * @param ctx 151 | */ 152 | const computeHourWidths: ( 153 | hourFormats: DateFormat, 154 | localeName: string, 155 | style: DynamicStyle, 156 | ctx: CanvasRenderingContext2D | null 157 | ) => { 158 | widths: CalendarItemWidths[]; 159 | maxWidths: CalendarItemWidths; 160 | formatted: Formatted; 161 | } = (hourFormats, localeName, style, ctx) => { 162 | const widths: CalendarItemWidths[] = []; 163 | const maxWidths: CalendarItemWidths = {}; 164 | const formatted: Formatted = { 165 | long: [], 166 | medium: [], 167 | short: [] 168 | }; 169 | if (ctx) { 170 | // 171 | const baseStyle = { 172 | ...style["calendar-row-text"], 173 | ...style["calendar-row-text--hour"] 174 | }; 175 | ctx.font = baseStyle["fontSize"] + " " + baseStyle["fontFamily"]; 176 | 177 | let currentDate = dayjs("2020-01-01T00:00:00").locale(localeName); // any date will be good for hours 178 | 179 | // Initialize maxWidths 180 | for (const formatName in hourFormats) { 181 | maxWidths[formatName] = 0; 182 | } 183 | // 计算Hour文本显示宽度 184 | for (let hour = 0; hour < 24; hour++) { 185 | const width: CalendarItemWidths = { hour }; 186 | for (const formatName in hourFormats) { 187 | const hourFormatted = hourFormats[formatName](currentDate); 188 | width[formatName] = ctx.measureText(hourFormatted).width; 189 | formatted[formatName].push(hourFormatted); 190 | // Calculate maxWidths 191 | if (width[formatName] > maxWidths[formatName]) { 192 | maxWidths[formatName] = width[formatName]; 193 | } 194 | } 195 | widths.push(width); 196 | currentDate = currentDate.add(1, "hour"); 197 | } 198 | } 199 | return { widths, maxWidths, formatted }; 200 | }; 201 | 202 | /** 203 | * 204 | * Compute calendar days column widths basing on text widths 205 | * @param steps options.times.steps 206 | * @param dayFormats options.calendar.day.format 207 | * @param localeName options.locale.name 208 | * @param style 209 | * @param ctx 210 | */ 211 | const computeDayWidths: ( 212 | steps: StepOption[], 213 | dayFormats: DateFormat, 214 | localeName: string, 215 | style: DynamicStyle, 216 | ctx: CanvasRenderingContext2D | null 217 | ) => { 218 | widths: CalendarItemWidths[]; 219 | maxWidths: CalendarItemWidths; 220 | } = (steps, dayFormats, localeName, style, ctx) => { 221 | const widths: CalendarItemWidths[] = []; 222 | const maxWidths: CalendarItemWidths = {}; 223 | if (ctx) { 224 | // 225 | const baseStyle = { 226 | ...style["calendar-row-text"], 227 | ...style["calendar-row-text--day"] 228 | }; 229 | ctx.font = baseStyle["fontSize"] + " " + baseStyle["fontFamily"]; 230 | 231 | let currentDate = dayjs(steps[0].time).locale(localeName); 232 | 233 | // Initialize maxWidths 234 | for (const formatName in dayFormats) { 235 | maxWidths[formatName] = 0; 236 | } 237 | // 计算Day文本显示宽度 238 | for (let day = 0, daysLen = steps.length; day < daysLen; day++) { 239 | const width: CalendarItemWidths = { day }; 240 | for (const formatName in dayFormats) { 241 | width[formatName] = ctx.measureText( 242 | dayFormats[formatName](currentDate) 243 | ).width; 244 | // Calculate maxWidths 245 | if (width[formatName] > maxWidths[formatName]) { 246 | maxWidths[formatName] = width[formatName]; 247 | } 248 | } 249 | widths.push(width); 250 | currentDate = currentDate.add(1, "day"); 251 | } 252 | } 253 | return { widths, maxWidths }; 254 | }; 255 | 256 | /** 257 | * Months count 258 | * 259 | * @description Returns number of different months in specified time range 260 | * 261 | * @param fromTime - date in ms 262 | * @param toTime - date in ms 263 | * 264 | * @returns {number} different months count 265 | */ 266 | const getMonthsCount = ( 267 | fromTime: dayjs.ConfigType, 268 | toTime: dayjs.ConfigType 269 | ): number => { 270 | let currentMonth = dayjs(fromTime); 271 | const endMonth = dayjs(toTime); 272 | if (currentMonth.valueOf() > endMonth.valueOf()) { 273 | return 0; 274 | } 275 | let previousMonth = currentMonth.clone(); 276 | let monthsCount = 1; 277 | while (currentMonth.valueOf() <= endMonth.valueOf()) { 278 | currentMonth = currentMonth.add(1, "day"); 279 | if (previousMonth.month() !== currentMonth.month()) { 280 | monthsCount++; 281 | } 282 | previousMonth = currentMonth.clone(); 283 | } 284 | return monthsCount; 285 | }; 286 | 287 | /** 288 | * Compute month calendar columns widths basing on text widths 289 | * @param firstTime options.times.firstTime 290 | * @param lastTime options.times.lastTime 291 | * @param monthFormats options.calendar.month.format 292 | * @param localeName options.locale.name 293 | * @param style 294 | * @param ctx 295 | */ 296 | const computeMonthWidths = ( 297 | firstTime: dayjs.ConfigType, 298 | lastTime: dayjs.ConfigType, 299 | monthFormats: DateFormat, 300 | localeName: string, 301 | style: DynamicStyle, 302 | ctx: CanvasRenderingContext2D | null 303 | ): { widths: CalendarItemWidths[]; maxWidths: CalendarItemWidths } => { 304 | const widths: CalendarItemWidths[] = []; 305 | const maxWidths: CalendarItemWidths = {}; 306 | if (ctx) { 307 | const baseStyle = { 308 | ...style["calendar-row-text"], 309 | ...style["calendar-row-text--month"] 310 | }; 311 | ctx.font = baseStyle["fontSize"] + " " + baseStyle["fontFamily"]; 312 | 313 | let currentDate = dayjs(firstTime).locale(localeName); 314 | const count = getMonthsCount(firstTime, lastTime); 315 | 316 | // Initialize maxWidths 317 | for (const formatName in monthFormats) { 318 | maxWidths[formatName] = 0; 319 | } 320 | // 计算Month文本显示宽度 321 | for (let month = 0; month < count; month++) { 322 | const width: CalendarItemWidths = { 323 | month 324 | }; 325 | for (const formatName in monthFormats) { 326 | width[formatName] = ctx.measureText( 327 | monthFormats[formatName](currentDate) 328 | ).width; 329 | // Calculate maxWidths 330 | if (width[formatName] > maxWidths[formatName]) { 331 | maxWidths[formatName] = width[formatName]; 332 | } 333 | } 334 | widths.push(width); 335 | currentDate = currentDate.add(1, "month"); 336 | } 337 | } 338 | return { widths, maxWidths }; 339 | }; 340 | 341 | /** 342 | * How many hours will fit? 343 | * 344 | * @param dateFormats options.calendar.hour.format 345 | * @param fullCellWidth options.times.steps[dayIndex].width.px 346 | * @param maxWidths options.calendar.hour.maxWidths 347 | */ 348 | const howManyHoursFit = ( 349 | dateFormats: DateFormat, 350 | fullCellWidth: number, 351 | maxWidths: CalendarItemWidths 352 | ): DateCount => { 353 | const stroke = 1; 354 | const additionalSpace = stroke + 2; 355 | for (let hours = 24; hours > 1; hours = Math.ceil(hours / 2)) { 356 | for (const formatName in dateFormats) { 357 | if ( 358 | (maxWidths[formatName] + additionalSpace) * hours <= fullCellWidth && 359 | hours > 1 360 | ) { 361 | return { 362 | count: hours, 363 | type: formatName 364 | }; 365 | } 366 | } 367 | } 368 | return { 369 | count: 0, 370 | type: "" 371 | }; 372 | }; 373 | 374 | /** 375 | * How many days will fit? 376 | * 377 | * @param dateFormats options.calendar.day.format 378 | * @param fullWidth options.width 379 | * @param maxWidths options.calendar.day.maxWidths 380 | * @param steps 381 | */ 382 | const howManyDaysFit = ( 383 | dateFormats: DateFormat, 384 | fullWidth: number, 385 | maxWidths: CalendarItemWidths, 386 | stepLength: number 387 | ): DateCount => { 388 | const stroke = 1; 389 | const additionalSpace = stroke + 2; 390 | 391 | for (let days = stepLength; days > 1; days = Math.ceil(days / 2)) { 392 | for (const formatName in dateFormats) { 393 | if ( 394 | (maxWidths[formatName] + additionalSpace) * days <= fullWidth && 395 | days > 1 396 | ) { 397 | return { 398 | count: days, 399 | type: formatName 400 | }; 401 | } 402 | } 403 | } 404 | return { 405 | count: 0, 406 | type: "" 407 | }; 408 | }; 409 | 410 | /** 411 | * How many months will fit? 412 | * 413 | * @param dateFormats options.calendar.month.format 414 | * @param fullWidth options.width 415 | * @param maxWidths options.calendar.month.maxWidths 416 | * @param monthsCount 417 | */ 418 | const howManyMonthsFit = ( 419 | dateFormats: DateFormat, 420 | fullWidth: number, 421 | maxWidths: CalendarItemWidths, 422 | monthsCount: number 423 | ): DateCount => { 424 | const stroke = 1; 425 | const additionalSpace = stroke + 2; 426 | 427 | if (monthsCount === 1) { 428 | for (const formatName in dateFormats) { 429 | if (maxWidths[formatName] + additionalSpace <= fullWidth) { 430 | return { 431 | count: 1, 432 | type: formatName 433 | }; 434 | } 435 | } 436 | } 437 | for (let months = monthsCount; months > 1; months = Math.ceil(months / 2)) { 438 | for (const formatName in dateFormats) { 439 | if ( 440 | (maxWidths[formatName] + additionalSpace) * months <= fullWidth && 441 | months > 1 442 | ) { 443 | return { 444 | count: months, 445 | type: formatName 446 | }; 447 | } 448 | } 449 | } 450 | return { 451 | count: 0, 452 | type: Object.keys(dateFormats)[0] 453 | }; 454 | }; 455 | 456 | /** 457 | * Sum all calendar rows height and return result 458 | * 459 | * @param hours 460 | * @param days 461 | * @param months 462 | * @param options 463 | */ 464 | const calculateCalendarDimensions = ( 465 | hours: CalendarRowItems[], 466 | days: CalendarRowItems[], 467 | months: CalendarRowItems[], 468 | options: Options 469 | ): number => { 470 | let height = 0; 471 | if (options.calendar.hour.display && hours && hours.length > 0) { 472 | height += options.calendar.hour.height; 473 | } 474 | if (options.calendar.day.display && days && days.length > 0) { 475 | height += options.calendar.day.height; 476 | } 477 | if (options.calendar.month.display && months && months.length > 0) { 478 | height += options.calendar.month.height; 479 | } 480 | return height; 481 | }; 482 | 483 | export { 484 | timePerPixel as calculateTimePerPixel, 485 | totalViewDurationMs as calculateTotalViewDurationMs, 486 | totalViewDurationPx as calculateTotalViewDurationPx, 487 | width as calculateWidth, 488 | getMonthsCount, 489 | calculateSteps, 490 | computeHourWidths, 491 | computeDayWidths, 492 | computeMonthWidths, 493 | calculateCalendarDimensions, 494 | howManyHoursFit, 495 | howManyDaysFit, 496 | howManyMonthsFit 497 | }; 498 | -------------------------------------------------------------------------------- /src/demo.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import GanttElastic from "./GanttElastic"; 5 | import "./style.css"; 6 | 7 | function getDate(hours: number): number { 8 | return ( 9 | dayjs() 10 | .startOf("day") 11 | .valueOf() + 12 | hours * 60 * 60 * 1000 13 | ); 14 | } 15 | 16 | const tasks = [ 17 | { 18 | id: 1, 19 | label: "Make some noise", 20 | user: 21 | 'John Doe', 22 | start: getDate(-24 * 5), 23 | duration: 5 * 24 * 60 * 60 * 1000, 24 | progress: 85, 25 | type: "project" 26 | }, 27 | { 28 | id: 2, 29 | label: "With great power comes great responsibility", 30 | user: 31 | 'Peter Parker', 32 | parentId: 1, 33 | start: getDate(-24 * 4), 34 | duration: 4 * 24 * 60 * 60 * 1000, 35 | progress: 50, 36 | type: "milestone", 37 | style: { 38 | base: { 39 | fill: "#1EBC61", 40 | stroke: "#0EAC51" 41 | } 42 | /*'tree-row-bar': { 43 | fill: '#1EBC61', 44 | stroke: '#0EAC51' 45 | }, 46 | 'tree-row-bar-polygon': { 47 | stroke: '#0EAC51' 48 | }*/ 49 | } 50 | }, 51 | { 52 | id: 3, 53 | label: "Courage is being scared to death, but saddling up anyway.", 54 | user: 55 | 'John Wayne', 56 | parentId: 2, 57 | start: getDate(-24 * 3), 58 | duration: 2 * 24 * 60 * 60 * 1000, 59 | progress: 100, 60 | type: "task" 61 | }, 62 | { 63 | id: 4, 64 | label: "Put that toy AWAY!", 65 | user: 66 | 'Clark Kent', 67 | start: getDate(-24 * 2), 68 | duration: 2 * 24 * 60 * 60 * 1000, 69 | progress: 50, 70 | type: "task", 71 | dependentOn: [3] 72 | }, 73 | { 74 | id: 5, 75 | label: 76 | "One billion, gajillion, fafillion... shabadylu...mil...shabady......uh, Yen.", 77 | user: 78 | 'Austin Powers', 79 | parentId: 4, 80 | start: getDate(0), 81 | duration: 2 * 24 * 60 * 60 * 1000, 82 | progress: 10, 83 | type: "milestone", 84 | style: { 85 | base: { 86 | fill: "#0287D0", 87 | stroke: "#0077C0" 88 | } 89 | } 90 | }, 91 | { 92 | id: 6, 93 | label: "Butch Mario and the Luigi Kid", 94 | user: 95 | 'Mario Bros', 96 | parentId: 5, 97 | start: getDate(24), 98 | duration: 1 * 24 * 60 * 60 * 1000, 99 | progress: 50, 100 | type: "task", 101 | style: { 102 | base: { 103 | fill: "#8E44AD", 104 | stroke: "#7E349D" 105 | } 106 | } 107 | }, 108 | { 109 | id: 7, 110 | label: "Devon, the old man wanted me, it was his dying request", 111 | user: 112 | 'Knight Rider', 113 | parentId: 2, 114 | dependentOn: [6], 115 | start: getDate(24 * 2), 116 | duration: 4 * 60 * 60 * 1000, 117 | progress: 20, 118 | type: "task" 119 | }, 120 | { 121 | id: 8, 122 | label: "Hey, Baby! Anybody ever tell you I have beautiful eyes?", 123 | user: 124 | 'Johhny Bravo', 125 | parentId: 7, 126 | dependentOn: [7], 127 | start: getDate(24 * 3), 128 | duration: 1 * 24 * 60 * 60 * 1000, 129 | progress: 0, 130 | type: "task" 131 | }, 132 | { 133 | id: 9, 134 | label: 135 | "This better be important, woman. You are interrupting my very delicate calculations.", 136 | user: 137 | 'Dexter\'s Laboratory', 138 | parentId: 8, 139 | dependentOn: [8, 7], 140 | start: getDate(24 * 4), 141 | duration: 4 * 60 * 60 * 1000, 142 | progress: 20, 143 | type: "task", 144 | style: { 145 | base: { 146 | fill: "#8E44AD", 147 | stroke: "#7E349D" 148 | } 149 | } 150 | }, 151 | { 152 | id: 10, 153 | label: "current task", 154 | user: ( 155 | 160 | Johnattan Owens 161 | 162 | ), 163 | start: getDate(24 * 5), 164 | duration: 24 * 60 * 60 * 1000, 165 | progress: 0, 166 | type: "task" 167 | } 168 | ]; 169 | 170 | const options = { 171 | title: { 172 | label: "Your project title as html (link or whatever...)", 173 | html: false 174 | }, 175 | times: { 176 | timeZoom: 10, 177 | firstTime: dayjs("2020/03/10").valueOf() 178 | }, 179 | row: { height: 16 }, 180 | taskList: { 181 | columns: [ 182 | { 183 | id: 1, 184 | label: "ID", 185 | value: "id", 186 | width: 40 187 | }, 188 | { 189 | id: 2, 190 | label: "Description", 191 | value: "label", 192 | width: 200, 193 | expander: true 194 | }, 195 | { 196 | id: 3, 197 | label: "Assigned to", 198 | value: "user", 199 | width: 130, 200 | html: true 201 | }, 202 | { 203 | id: 4, 204 | label: "Start", 205 | value: task => dayjs(task.start).format("YYYY-MM-DD"), 206 | width: 78 207 | }, 208 | { 209 | id: 5, 210 | label: "Type", 211 | value: "type", 212 | width: 68 213 | }, 214 | { 215 | id: 6, 216 | label: "%", 217 | value: "progress", 218 | width: 35, 219 | style: { 220 | "task-list-header-label": { 221 | textAlign: "center", 222 | width: "100%" 223 | }, 224 | "task-list-item-value-container": { 225 | textAlign: "center" 226 | } 227 | } 228 | } 229 | ] 230 | } 231 | // locale: { 232 | // name: "pl", // name String 233 | // weekdays: "Poniedziałek_Wtorek_Środa_Czwartek_Piątek_Sobota_Niedziela".split( 234 | // "_" 235 | // ), // weekdays Array 236 | // weekdaysShort: "Pon_Wto_Śro_Czw_Pią_Sob_Nie".split("_"), // OPTIONAL, short weekdays Array, use first three letters if not provided 237 | // weekdaysMin: "Pn_Wt_Śr_Cz_Pt_So_Ni".split("_"), // OPTIONAL, min weekdays Array, use first two letters if not provided 238 | // months: "Styczeń_Luty_Marzec_Kwiecień_Maj_Czerwiec_Lipiec_Sierpień_Wrzesień_Październik_Listopad_Grudzień".split( 239 | // "_" 240 | // ), // months Array 241 | // monthsShort: "Sty_Lut_Mar_Kwi_Maj_Cze_Lip_Sie_Wrz_Paź_Lis_Gru".split("_"), // OPTIONAL, short months Array, use first three letters if not provided 242 | // ordinal: n => `${n}`, // ordinal Function (number) => return number + output 243 | // relativeTime: { 244 | // // relative time format strings, keep %s %d as the same 245 | // future: "za %s", // e.g. in 2 hours, %s been replaced with 2hours 246 | // past: "%s temu", 247 | // s: "kilka sekund", 248 | // m: "minutę", 249 | // mm: "%d minut", 250 | // h: "godzinę", 251 | // hh: "%d godzin", // e.g. 2 hours, %d been replaced with 2 252 | // d: "dzień", 253 | // dd: "%d dni", 254 | // M: "miesiąc", 255 | // MM: "%d miesięcy", 256 | // y: "rok", 257 | // yy: "%d lat" 258 | // } 259 | // } 260 | }; 261 | 262 | ReactDOM.render( 263 | , 270 | document.getElementById("root") 271 | ); 272 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | [class^="gantt-elastic"], 2 | [class*=" gantt-elastic"] { 3 | box-sizing: border-box; 4 | } 5 | .gantt-elastic__main-view svg { 6 | display: block; 7 | } 8 | .gantt-elastic__grid-horizontal-line, 9 | .gantt-elastic__grid-vertical-line { 10 | stroke: #a0a0a0; 11 | stroke-width: 1; 12 | } 13 | foreignObject > * { 14 | margin: 0px; 15 | } 16 | .gantt-elastic .p-2 { 17 | padding: 10rem; 18 | } 19 | .gantt-elastic__main-view-main-container, 20 | .gantt-elastic__main-view-container { 21 | overflow: hidden; 22 | max-width: 100%; 23 | } 24 | .gantt-elastic__task-list-header-column:last-of-type { 25 | border-right: 1px solid #00000050; 26 | } 27 | .gantt-elastic__task-list-item:last-of-type { 28 | border-bottom: 1px solid #00000050; 29 | } 30 | .gantt-elastic__task-list-item-value-wrapper:hover { 31 | overflow: visible !important; 32 | } 33 | .gantt-elastic__task-list-item-value-wrapper:hover 34 | > .gantt-elastic__task-list-item-value-container { 35 | position: relative; 36 | overflow: visible !important; 37 | } 38 | .gantt-elastic__task-list-item-value-wrapper:hover 39 | > .gantt-elastic__task-list-item-value { 40 | position: absolute; 41 | } 42 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Task, TaskListColumnOption } from "./components/interfaces"; 3 | 4 | declare interface DynamicStyle { 5 | [key: string]: { 6 | [key: string]: string | number; 7 | }; 8 | } 9 | 10 | declare interface GanttElasticTask { 11 | id: string | number; // * 12 | parentId?: string | number; 13 | dependentOn?: string[] | number[]; 14 | start: dayjs.ConfigType; // * 15 | startTime?: number; 16 | end?: dayjs.ConfigType; 17 | endTime?: number; 18 | label: string; // * 19 | duration: number; // * 20 | progress: number; // * 21 | type?: string; 22 | style?: DynamicStyle; 23 | collapsed?: boolean; 24 | events?: { 25 | [key: string]: ( 26 | event: React.MouseEvent | React.TouchEvent, 27 | task: Task 28 | ) => void; 29 | }; 30 | } 31 | 32 | declare interface GanttElasticOptions { 33 | scroll?: { 34 | dragXMoveMultiplier?: number; //* 35 | dragYMoveMultiplier?: number; //* 36 | }; 37 | scope?: { 38 | //* 39 | before: number; 40 | after: number; 41 | }; 42 | times?: { 43 | timeScale?: number; 44 | timeZoom?: number; //* 45 | firstTime?: number; 46 | lastTime?: number; 47 | timePerPixel?: number; // 计算值,设置无效 48 | stepDuration?: dayjs.UnitType; 49 | }; 50 | row: { 51 | height?: number; //* 52 | }; 53 | maxRows?: number; //* 54 | maxHeight?: number; //* 55 | chart?: { 56 | grid?: { 57 | horizontal: { 58 | gap: number; //* 59 | }; 60 | }; 61 | progress?: { 62 | width?: number; //* 63 | height?: number; //* 64 | pattern?: boolean; 65 | bar?: boolean; 66 | }; 67 | text?: { 68 | offset?: number; //* 69 | xPadding?: number; //* 70 | display?: boolean; //* 71 | }; 72 | expander?: { 73 | type?: string; 74 | display?: boolean; //* 75 | displayIfTaskListHidden?: boolean; //* 76 | offset?: number; //* 77 | size?: number; 78 | }; 79 | }; 80 | taskList?: { 81 | display?: boolean; //* 82 | resizeAfterThreshold?: boolean; //* 83 | widthThreshold?: number; //* 84 | columns?: GanttElasticTaskListColumn[]; 85 | percent?: number; //* 86 | minWidth?: number; 87 | expander?: { 88 | type?: string; 89 | size?: number; 90 | columnWidth?: number; 91 | padding?: number; 92 | margin?: number; 93 | straight?: boolean; 94 | }; 95 | }; 96 | calendar?: GanttElasticCalendar; 97 | locale?: ILocale; 98 | } 99 | 100 | declare interface GanttElasticTaskListColumn { 101 | id: number; 102 | label: string; 103 | value: string | Function; 104 | style?: DynamicStyle; 105 | width: number; 106 | events?: { 107 | [key: string]: (a: { 108 | event: React.MouseEvent | React.TouchEvent; 109 | data: Task; 110 | column: TaskListColumnOption; 111 | }) => void; 112 | }; 113 | expander?: boolean; 114 | } 115 | 116 | declare interface GanttElasticCalendar { 117 | workingDays?: number[]; //* 118 | gap?: number; //* 119 | strokeWidth?: number; 120 | hour?: { 121 | height?: number; //* 122 | display?: boolean; //* 123 | format?: DateFormat; //* 124 | }; 125 | day?: { 126 | height?: number; //* 127 | display?: boolean; //* 128 | format?: DateFormat; //* 129 | }; 130 | month?: { 131 | height?: number; //* 132 | display?: boolean; //* 133 | format?: DateFormat; //* 134 | }; 135 | } 136 | 137 | declare interface DateFormat { 138 | long?: (date: dayjs.Dayjs) => string; 139 | medium?: (date: dayjs.Dayjs) => string; 140 | short?: (date: dayjs.Dayjs) => string; 141 | [key: string]: (date: dayjs.Dayjs) => string; 142 | } 143 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": false, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "sourceMap": true, 18 | "baseUrl": "./", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | }, 22 | // 23 | "noImplicitAny": true, 24 | "removeComments": true 25 | }, 26 | "awesomeTypescriptLoaderOptions": { 27 | /* ... */ 28 | } 29 | // "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const TerserPlugin = require("terser-webpack-plugin"); 3 | const { CheckerPlugin } = require("awesome-typescript-loader"); 4 | const LodashModuleReplacementPlugin = require("lodash-webpack-plugin"); 5 | 6 | module.exports = [ 7 | { 8 | mode: "production", 9 | optimization: { 10 | minimize: false 11 | }, 12 | entry: "./src/GanttElastic.tsx", 13 | output: { 14 | filename: "GanttElastic.js", 15 | // eslint-disable-next-line no-undef 16 | path: path.join(__dirname, "./dist"), 17 | library: "GanttElastic", 18 | libraryTarget: "commonjs2", 19 | libraryExport: "default" 20 | }, 21 | // Enable sourcemaps for debugging webpack's output. 22 | devtool: "source-map", 23 | resolve: { 24 | // Add '.ts' and '.tsx' as resolvable extensions. 25 | extensions: [".ts", ".tsx", ".js", ".json"], 26 | alias: { 27 | // eslint-disable-next-line no-undef 28 | "@": path.join(__dirname, "src") 29 | } 30 | }, 31 | module: { 32 | rules: [ 33 | // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'. 34 | { 35 | test: /\.tsx?$/, 36 | exclude: /node_modules/, 37 | // loader: "awesome-typescript-loader", 38 | use: { 39 | loader: "babel-loader", 40 | options: { 41 | presets: ["@babel/preset-env"], 42 | plugins: ["lodash"] 43 | } 44 | } 45 | } 46 | ] 47 | }, 48 | // When importing a module whose path matches one of the following, just 49 | // assume a corresponding global variable exists and use that instead. 50 | // This is important because it allows us to avoid bundling all of our 51 | // dependencies, which allows browsers to cache those libraries between builds. 52 | externals: [ 53 | { 54 | react: "React", 55 | "react-dom": "ReactDOM" 56 | }, 57 | /^(demo|\$)$/i 58 | ], 59 | plugins: [new CheckerPlugin(), new LodashModuleReplacementPlugin()] 60 | }, 61 | { 62 | mode: "production", 63 | optimization: { 64 | minimize: true, 65 | namedModules: false, 66 | minimizer: [ 67 | new TerserPlugin({ 68 | terserOptions: { 69 | mangle: false 70 | } 71 | }) 72 | ] 73 | }, 74 | entry: "./src/GanttElastic.tsx", 75 | output: { 76 | filename: "GanttElastic.min.js", 77 | // eslint-disable-next-line no-undef 78 | path: path.join(__dirname, "./dist"), 79 | library: "GanttElastic", 80 | libraryTarget: "commonjs2", 81 | libraryExport: "default" 82 | }, 83 | // Enable sourcemaps for debugging webpack's output. 84 | devtool: "source-map", 85 | resolve: { 86 | // Add '.ts' and '.tsx' as resolvable extensions. 87 | extensions: [".ts", ".tsx", ".js", ".json"], 88 | alias: { 89 | // eslint-disable-next-line no-undef 90 | "@": path.join(__dirname, "src") 91 | } 92 | }, 93 | module: { 94 | rules: [ 95 | // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'. 96 | { 97 | test: /\.tsx?$/, 98 | exclude: /node_modules/, 99 | // loader: "awesome-typescript-loader" 100 | use: { 101 | loader: "babel-loader", 102 | options: { 103 | presets: ["@babel/preset-env"], 104 | plugins: ["lodash"] 105 | } 106 | } 107 | } 108 | ] 109 | }, 110 | // When importing a module whose path matches one of the following, just 111 | // assume a corresponding global variable exists and use that instead. 112 | // This is important because it allows us to avoid bundling all of our 113 | // dependencies, which allows browsers to cache those libraries between builds. 114 | externals: [ 115 | { 116 | react: "React", 117 | "react-dom": "ReactDOM" 118 | }, 119 | /^(demo|\$)$/i 120 | ], 121 | plugins: [new CheckerPlugin(), new LodashModuleReplacementPlugin()] 122 | } 123 | ]; 124 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const path = require("path"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin"); 5 | const errorOverlayMiddleware = require("react-dev-utils/errorOverlayMiddleware"); 6 | const evalSourceMapMiddleware = require("react-dev-utils/evalSourceMapMiddleware"); 7 | const WatchMissingNodeModulesPlugin = require("react-dev-utils/WatchMissingNodeModulesPlugin"); 8 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 9 | 10 | module.exports = { 11 | mode: "development", 12 | // Enable sourcemaps for debugging webpack's output. 13 | devtool: "source-map", 14 | entry: [ 15 | "./src/demo.tsx", 16 | require.resolve("react-dev-utils/webpackHotDevClient") 17 | ], 18 | output: { 19 | // Add /* filename */ comments to generated require()s in the output. 20 | // pathinfo: true, 21 | // There will be one main bundle, and one file per asynchronous chunk. 22 | // In development, it does not produce real files. 23 | filename: "static/js/bundle.js", 24 | // There are also additional JS chunk files if you use code splitting. 25 | chunkFilename: "static/js/[name].chunk.js", 26 | // We inferred the "public path" (such as / or /my-project) from homepage. 27 | // We use "/" in development. 28 | publicPath: "/" 29 | }, 30 | resolve: { 31 | // Add '.ts' and '.tsx' as resolvable extensions. 32 | extensions: [".ts", ".tsx", ".js", ".json"], 33 | alias: { 34 | // eslint-disable-next-line no-undef 35 | "@": path.join(__dirname, "src") 36 | } 37 | }, 38 | devServer: { 39 | watchOptions: { 40 | aggregateTimeout: 300, 41 | poll: 1000 42 | }, 43 | // Enable gzip compression of generated files. 44 | compress: true, 45 | // Silence WebpackDevServer's own logs since they're generally not useful. 46 | // It will still show compile warnings and errors with this setting. 47 | clientLogLevel: "info", 48 | // By default WebpackDevServer serves physical files from current directory 49 | // in addition to all the virtual build products that it serves from memory. 50 | // This is confusing because those files won’t automatically be available in 51 | // production build folder unless we copy them. However, copying the whole 52 | // project directory is dangerous because we may expose sensitive files. 53 | // Instead, we establish a convention that only files in `public` directory 54 | // get served. Our build script will copy `public` into the `build` folder. 55 | // In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%: 56 | // 57 | // In JavaScript code, you can access it with `process.env.PUBLIC_URL`. 58 | // Note that we only recommend to use `public` folder as an escape hatch 59 | // for files like `favicon.ico`, `manifest.json`, and libraries that are 60 | // for some reason broken when imported through Webpack. If you just want to 61 | // use an image, put it in `src` and `import` it from JavaScript instead. 62 | // contentBase: path.join(__dirname, "./dist"), 63 | // By default files from `contentBase` will not trigger a page reload. 64 | watchContentBase: true, 65 | // Enable hot reloading server. It will provide /sockjs-node/ endpoint 66 | // for the WebpackDevServer client so it can learn when the files were 67 | // updated. The WebpackDevServer client is included as an entry point 68 | // in the Webpack development configuration. Note that only changes 69 | // to CSS are currently hot reloaded. JS changes will refresh the browser. 70 | hot: true, 71 | port: 3000, 72 | // Use 'ws' instead of 'sockjs-node' on server since we're using native 73 | // websockets in `webpackHotDevClient`. 74 | transportMode: "ws", 75 | // Prevent a WS client from getting injected as we're already including 76 | // `webpackHotDevClient`. 77 | injectClient: false, 78 | // It is important to tell WebpackDevServer to use the same "root" path 79 | // as we specified in the config. In development, we always serve from /. 80 | publicPath: "/", 81 | before(app, server) { 82 | // This lets us fetch source contents from webpack for the error overlay 83 | app.use(evalSourceMapMiddleware(server)); 84 | // This lets us open files from the runtime error overlay. 85 | app.use(errorOverlayMiddleware()); 86 | // This service worker file is effectively a 'no-op' that will reset any 87 | // previous service worker registered for the same host:port combination. 88 | // We do this in development to avoid hitting the production cache if 89 | // it used the same host and port. 90 | // https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432 91 | // app.use(noopServiceWorkerMiddleware()); 92 | } 93 | }, 94 | module: { 95 | rules: [ 96 | // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'. 97 | // { test: /\.tsx?$/, loader: "awesome-typescript-loader" } 98 | { 99 | test: /\.tsx?$/, 100 | exclude: /(node_modules|bower_components)/, 101 | use: { 102 | loader: "babel-loader", 103 | options: { 104 | presets: ["@babel/preset-env"] 105 | } 106 | } 107 | }, 108 | { 109 | test: /\.css$/, 110 | use: [ 111 | { 112 | loader: MiniCssExtractPlugin.loader 113 | }, 114 | "css-loader" 115 | ] 116 | } 117 | ] 118 | }, 119 | plugins: [ 120 | // Generates an `index.html` file with the