├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── demo ├── .env ├── ops │ └── deploy.sh ├── package-lock.json ├── package.json ├── public │ └── index.html └── src │ ├── App.jsx │ ├── App2.jsx │ ├── builders.js │ ├── index.css │ ├── index.jsx │ └── utils.js ├── img └── 1.jpg ├── jestSetup.js ├── package.json └── src ├── components ├── Layout.jsx ├── Sidebar │ ├── ProjectKey.jsx │ └── ProjectKeys.jsx └── Timeline │ ├── Marker.jsx │ ├── Now.jsx │ ├── Pointer.jsx │ ├── Project.jsx │ ├── Projects.jsx │ ├── Task.jsx │ ├── TaskBasic.jsx │ └── Timebar.jsx ├── hooks └── useEvent.jsx ├── index.jsx ├── scss ├── _utils.scss ├── components │ ├── _controls.scss │ ├── _grid.scss │ ├── _layout.scss │ ├── _marker.scss │ ├── _project-key.scss │ ├── _project-keys.scss │ ├── _project.scss │ ├── _projects.scss │ ├── _sidebar.scss │ ├── _task.scss │ ├── _timebar-key.scss │ ├── _timebar.scss │ └── _timeline.scss └── style.scss └── utils ├── __tests__ ├── classes.js ├── formatDate.js ├── getGrid.js ├── getMouseX.js ├── getNumericPropertyValue.js └── time.js ├── classes.js ├── formatDate.js ├── getGrid.js ├── getMouseX.js └── time.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | [ 5 | "@babel/env", 6 | { 7 | "targets": { 8 | "browsers": ["last 2 versions", "ie 9-11"], 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ], 14 | "plugins": ["@babel/plugin-proposal-class-properties"] 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs. 2 | # Requires EditorConfig JetBrains Plugin - http://github.com/editorconfig/editorconfig-jetbrains 3 | 4 | # Set this file as the topmost .editorconfig 5 | # (multiple files can be used, and are applied starting from current document location) 6 | root = true 7 | 8 | # Use bracketed regexp to target specific file types or file locations 9 | [*.{js,json}] 10 | 11 | # Use hard or soft tabs ["tab", "space"] 12 | indent_style = space 13 | 14 | # Size of a single indent [an integer, "tab"] 15 | indent_size = tab 16 | 17 | # Number of columns representing a tab character [an integer] 18 | tab_width = 2 19 | 20 | # Line breaks representation ["lf", "cr", "crlf"] 21 | end_of_line = lf 22 | 23 | # ["latin1", "utf-8", "utf-16be", "utf-16le"] 24 | charset = utf-8 25 | 26 | # Remove any whitespace characters preceding newline characters ["true", "false"] 27 | trim_trailing_whitespace = true 28 | 29 | # Ensure file ends with a newline when saving ["true", "false"] 30 | insert_final_newline = true 31 | 32 | # Markdown files 33 | [*.md] 34 | 35 | # Trailing whitespaces are significant in Markdown. 36 | trim_trailing_whitespace = false 37 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | demo/node_modules 3 | build 4 | node_modules 5 | coverage 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "jest": true 6 | }, 7 | "extends": ["airbnb", "prettier", "prettier/react"], 8 | "rules": { 9 | "semi": ["error", "never"], 10 | "react/require-default-props": "off", 11 | "import/no-cycle": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .yarnclean 6 | yarn-error.log 7 | yarn.lock 8 | build 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: false, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 icrdr 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-gantt-antd 2 | A beautiful react gantt component with antd style. 3 | This is a fork of [JSainsburyPLC/react-timelines](https://github.com/JSainsburyPLC/react-timelines) 4 | 5 | ![snapshot](https://github.com/icrdr/react-gantt-antd/raw/master/img/1.jpg) 6 | 7 | ## Install 8 | 9 | ```sh 10 | yarn add react-gantt-antd 11 | ``` 12 | ## Example 13 | 14 | ```js 15 | import React from 'react' 16 | import Gantt from 'react-gantt-antd' 17 | import 'react-gantt-antd/lib/css/style.css' 18 | 19 | export default function App() { 20 | const tasks_a = [ 21 | { 22 | id: "title1", 23 | title: "任务名称", 24 | start: new Date('2020-06-01'), 25 | end: new Date('2020-08-02'), 26 | } 27 | ] 28 | 29 | const tasks_b = [ 30 | { 31 | id: "title1", 32 | title: "任务名称", 33 | start: new Date('2020-07-01'), 34 | end: new Date('2020-09-02'), 35 | } 36 | ] 37 | 38 | const sub_projects = [ 39 | { 40 | id: "sub_project1", 41 | title: "子项目", 42 | tasks: tasks_b, 43 | } 44 | ] 45 | 46 | const projects = [ 47 | { 48 | id: "project1", 49 | title: "项目1", 50 | tasks: tasks_a, 51 | projects: sub_projects, 52 | isOpen: false, 53 | } 54 | ] 55 | return ( 56 | 65 | ) 66 | } 67 | 68 | export default App 69 | ``` 70 | ## API 71 | ### Gantt 72 | | Property | value | default | Descriptions | 73 | | :-----:| :----: | :----: | :---- | 74 | | start | Date || The start date of the timeline | 75 | | end | Date || The start date of the timeline | 76 | | now | Date |new Date()| 'now' marker position | 77 | | zoom | Number |1| The scale of the timeline width | 78 | | projects | List |[]| The project list | 79 | | minWidth | Number |120| The min width of the timeline when resizing the window | 80 | | sideWidth | Number |400| The width of the sidebar | 81 | | clickTask | function || when click task element | 82 | | enableSticky | Bool |true| Determine whether the header is sticky or not | 83 | | scrollToNow | Bool |true| Determine whether to scroll to the now marker at first or not | 84 | 85 | ### Project 86 | | Property | value | default | Descriptions | 87 | | :-----:| :----: | :----: | :---- | 88 | | id | String/Number || The id of the Project | 89 | | title | String/Element || The title of the Project | 90 | | tasks | List || All the tasks of the Project | 91 | | projects | List || All the sub projects of the Project | 92 | | isOpen | Bool |false| Determine whether the project is folded not | 93 | 94 | ### Task 95 | | Property | value | default | Descriptions | 96 | | :-----:| :----: | :----: | :---- | 97 | | id | String/Number || The id of the Task | 98 | | title | String/Element || The title of the Task | 99 | | start | Date || The start date of the Task | 100 | | end | Date || The start date of the Task | 101 | 102 | ## Development 103 | 104 | ```sh 105 | yarn install 106 | yarn watch 107 | yarn build 108 | ``` 109 | 110 | 111 | ``` 112 | npm config set registry=http://registry.npmjs.org 113 | npm config set registry=https://registry.npm.taobao.org/ 114 | ``` 115 | -------------------------------------------------------------------------------- /demo/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /demo/ops/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Fail on the first error, rather than continuing 4 | set -e 5 | 6 | cd "$(dirname "$0")" 7 | 8 | COMMIT_EMAIL="${GITHUB_ACTOR}@users.noreply.github.com" 9 | COMMIT_NAME="${GITHUB_ACTOR}" 10 | 11 | git config --global user.email "${COMMIT_EMAIL}" 12 | git config --global user.name "${COMMIT_NAME}" 13 | 14 | npm install 15 | npm run build 16 | npm run deploy 17 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "antd": "^3.22.2", 7 | "react": "link:../node_modules/react", 8 | "react-dom": "link:../node_modules/react-dom", 9 | "react-scripts": "3.0.1", 10 | "react-gantt-antd": "link:.." 11 | }, 12 | "scripts": { 13 | "start": "react-scripts start", 14 | "build": "react-scripts build", 15 | "deploy": "gh-pages -d ./build" 16 | }, 17 | "devDependencies": { 18 | "gh-pages": "^2.0.1" 19 | }, 20 | "browserslist": { 21 | "production": [ 22 | ">0.2%", 23 | "not dead", 24 | "not op_mini all" 25 | ], 26 | "development": [ 27 | "last 1 chrome version", 28 | "last 1 firefox version", 29 | "last 1 safari version" 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Timelines 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /demo/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import React, { useState } from 'react' 3 | import { Button } from 'antd' 4 | 5 | import Gantt from 'react-gantt-antd' 6 | import 'react-gantt-antd/lib/css/style.css' 7 | 8 | import { buildProject } from './builders' 9 | import { fill } from './utils' 10 | 11 | const projectsById = fill(20).reduce((acc, i) => { 12 | const project = buildProject(i + 1) 13 | acc[project.id] = project 14 | return acc 15 | }, {}) 16 | 17 | export default function App() { 18 | const [zoom, setZoom] = useState(1) 19 | const projects = Object.values(projectsById) 20 | 21 | return ( 22 | <> 23 |
24 | 31 | 38 |
39 | 48 |
49 | 50 | ) 51 | } -------------------------------------------------------------------------------- /demo/src/App2.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Gantt from 'react-gantt-antd' 3 | import 'react-gantt-antd/lib/css/style.css' 4 | 5 | export default function App2() { 6 | const tasks_a = [ 7 | { 8 | id: "title1", 9 | title: "任务名称", 10 | start: new Date('2020-06-01'), 11 | end: new Date('2020-08-02'), 12 | } 13 | ] 14 | 15 | const tasks_b = [ 16 | { 17 | id: "title1", 18 | title: "任务名称", 19 | start: new Date('2020-07-01'), 20 | end: new Date('2020-09-02'), 21 | } 22 | ] 23 | 24 | const sub_projects = [ 25 | { 26 | id: "sub_project1", 27 | title: "子项目", 28 | tasks: tasks_b, 29 | } 30 | ] 31 | 32 | const projects = [ 33 | { 34 | id: "project1", 35 | title: "项目1", 36 | tasks: tasks_a, 37 | projects: sub_projects, 38 | isOpen: false, 39 | } 40 | ] 41 | return ( 42 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /demo/src/builders.js: -------------------------------------------------------------------------------- 1 | import { fill, hexToRgb, colourIsLight, addMonthsToYearAsDate, nextColor, randomTitle } from './utils' 2 | 3 | const START_YEAR = 2020 4 | const NUM_OF_YEARS = 1 5 | const MONTHS_PER_YEAR = 12 6 | const NUM_OF_MONTHS = NUM_OF_YEARS * MONTHS_PER_YEAR 7 | const MAX_TRACK_START_GAP = 1 8 | const MAX_ELEMENT_GAP = 8 9 | const MAX_MONTH_SPAN = 2 10 | const MIN_MONTH_SPAN = 1 11 | const MAX_NUM_OF_SUBTRACKS = 5 12 | 13 | export const buildTask = ({ projectId, start, end, i }) => { 14 | const bgColor = nextColor() 15 | const color = colourIsLight(...hexToRgb(bgColor)) ? '#000000' : '#ffffff' 16 | return { 17 | id: `t-${projectId}-el-${i}`, 18 | title: randomTitle(), 19 | start, 20 | end, 21 | style: { 22 | bgColor, 23 | color, 24 | }, 25 | } 26 | } 27 | 28 | export const buildProjectStartGap = () => Math.floor(Math.random() * MAX_TRACK_START_GAP) 29 | export const buildTaskGap = () => Math.floor(Math.random() * MAX_ELEMENT_GAP) 30 | export const buildTasks = projectId => { 31 | const v = [] 32 | let i = 1 33 | let month = buildProjectStartGap() 34 | 35 | while (month < NUM_OF_MONTHS) { 36 | let monthSpan = Math.floor(Math.random() * (MAX_MONTH_SPAN - (MIN_MONTH_SPAN - 1))) + MIN_MONTH_SPAN 37 | 38 | if (month + monthSpan > NUM_OF_MONTHS) { 39 | monthSpan = NUM_OF_MONTHS - month 40 | } 41 | 42 | const start = addMonthsToYearAsDate(START_YEAR, month) 43 | const end = addMonthsToYearAsDate(START_YEAR, month + monthSpan) 44 | v.push( 45 | buildTask({ 46 | projectId, 47 | start, 48 | end, 49 | i, 50 | }) 51 | ) 52 | const gap = buildTaskGap() 53 | month += monthSpan + gap 54 | i += 1 55 | } 56 | 57 | return v 58 | } 59 | 60 | export const buildSubproject = (projectId, subprojectId) => ({ 61 | id: `project-${projectId}-${subprojectId}`, 62 | title: `子项目 ${subprojectId}`, 63 | tasks: buildTasks(subprojectId), 64 | }) 65 | 66 | export const buildProject = projectId => { 67 | const projects = fill(Math.floor(Math.random() * MAX_NUM_OF_SUBTRACKS) + 1).map(i => buildSubproject(projectId, i + 1)) 68 | return { 69 | id: `project-${projectId}`, 70 | title: `项目 ${projectId}`, 71 | tasks: buildTasks(projectId), 72 | projects, 73 | isOpen: false, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | border: 0; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | @import '~antd/dist/antd.css'; 9 | -------------------------------------------------------------------------------- /demo/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | import './index.css' 7 | 8 | ReactDOM.render(, document.getElementById('app')) 9 | -------------------------------------------------------------------------------- /demo/src/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | export const fill = n => { 3 | const arr = [] 4 | for (let i = 0; i < n; i += 1) { 5 | arr.push(i) 6 | } 7 | return arr 8 | } 9 | 10 | const COLORS = ['FF005D', '0085B6', '0BB4C1', '00D49D', 'FEDF03', '233D4D', 'FE7F2D', 'FCCA46', 'A1C181', '579C87'] 11 | 12 | export const randomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)] 13 | 14 | let color = -1 15 | export const nextColor = () => { 16 | color = (color + 1) % COLORS.length 17 | return COLORS[color] 18 | } 19 | 20 | export const hexToRgb = hex => { 21 | const v = parseInt(hex, 16) 22 | const r = (v >> 16) & 255 23 | const g = (v >> 8) & 255 24 | const b = v & 255 25 | return [r, g, b] 26 | } 27 | 28 | export const colourIsLight = (r, g, b) => { 29 | const a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255 30 | return a < 0.5 31 | } 32 | 33 | export const addMonthsToYear = (year, monthsToAdd) => { 34 | let y = year 35 | let m = monthsToAdd 36 | while (m >= 12) { 37 | m -= 12 38 | y += 1 39 | } 40 | return { year: y, month: m + 1 } 41 | } 42 | 43 | export const addMonthsToYearAsDate = (year, monthsToAdd) => { 44 | const r = addMonthsToYear(year, monthsToAdd) 45 | return new Date(`${r.year}-${r.month}`) 46 | } 47 | 48 | // Credit: https://jsfiddle.net/katowulf/3gtDf/ 49 | const ADJECTIVES = [ 50 | 'adamant', 51 | 'adroit', 52 | 'amatory', 53 | 'animistic', 54 | 'antic', 55 | 'arcadian', 56 | 'baleful', 57 | 'bellicose', 58 | 'bilious', 59 | 'boorish', 60 | 'calamitous', 61 | 'caustic', 62 | 'cerulean', 63 | 'comely', 64 | 'concomitant', 65 | 'contumacious', 66 | 'corpulent', 67 | 'crapulous', 68 | 'defamatory', 69 | 'didactic', 70 | 'dilatory', 71 | 'dowdy', 72 | 'efficacious', 73 | 'effulgent', 74 | 'egregious', 75 | 'endemic', 76 | 'equanimous', 77 | 'execrable', 78 | 'fastidious', 79 | 'feckless', 80 | 'fecund', 81 | 'friable', 82 | 'fulsome', 83 | 'garrulous', 84 | 'guileless', 85 | 'gustatory', 86 | 'heuristic', 87 | 'histrionic', 88 | 'hubristic', 89 | 'incendiary', 90 | 'insidious', 91 | 'insolent', 92 | 'intransigent', 93 | 'inveterate', 94 | 'invidious', 95 | 'irksome', 96 | 'jejune', 97 | 'jocular', 98 | 'judicious', 99 | 'lachrymose', 100 | 'limpid', 101 | 'loquacious', 102 | 'luminous', 103 | 'mannered', 104 | 'mendacious', 105 | 'meretricious', 106 | 'minatory', 107 | 'mordant', 108 | 'munificent', 109 | 'nefarious', 110 | 'noxious', 111 | 'obtuse', 112 | 'parsimonious', 113 | 'pendulous', 114 | 'pernicious', 115 | 'pervasive', 116 | 'petulant', 117 | 'platitudinous', 118 | 'precipitate', 119 | 'propitious', 120 | 'puckish', 121 | 'querulous', 122 | 'quiescent', 123 | 'rebarbative', 124 | 'recalcitant', 125 | 'redolent', 126 | 'rhadamanthine', 127 | 'risible', 128 | 'ruminative', 129 | 'sagacious', 130 | 'salubrious', 131 | 'sartorial', 132 | 'sclerotic', 133 | 'serpentine', 134 | 'spasmodic', 135 | 'strident', 136 | 'taciturn', 137 | 'tenacious', 138 | 'tremulous', 139 | 'trenchant', 140 | 'turbulent', 141 | 'turgid', 142 | 'ubiquitous', 143 | 'uxorious', 144 | 'verdant', 145 | 'voluble', 146 | 'voracious', 147 | 'wheedling', 148 | 'withering', 149 | 'zealous', 150 | ] 151 | const NOUNS = [ 152 | 'ninja', 153 | 'chair', 154 | 'pancake', 155 | 'statue', 156 | 'unicorn', 157 | 'rainbows', 158 | 'laser', 159 | 'senor', 160 | 'bunny', 161 | 'captain', 162 | 'nibblets', 163 | 'cupcake', 164 | 'carrot', 165 | 'gnomes', 166 | 'glitter', 167 | 'potato', 168 | 'salad', 169 | 'toejam', 170 | 'curtains', 171 | 'beets', 172 | 'toilet', 173 | 'exorcism', 174 | 'stick figures', 175 | 'mermaid eggs', 176 | 'sea barnacles', 177 | 'dragons', 178 | 'jellybeans', 179 | 'snakes', 180 | 'dolls', 181 | 'bushes', 182 | 'cookies', 183 | 'apples', 184 | 'ice cream', 185 | 'ukulele', 186 | 'kazoo', 187 | 'banjo', 188 | 'opera singer', 189 | 'circus', 190 | 'trampoline', 191 | 'carousel', 192 | 'carnival', 193 | 'locomotive', 194 | 'hot air balloon', 195 | 'praying mantis', 196 | 'animator', 197 | 'artisan', 198 | 'artist', 199 | 'colorist', 200 | 'inker', 201 | 'coppersmith', 202 | 'director', 203 | 'designer', 204 | 'flatter', 205 | 'stylist', 206 | 'leadman', 207 | 'limner', 208 | 'make-up artist', 209 | 'model', 210 | 'musician', 211 | 'penciller', 212 | 'producer', 213 | 'scenographer', 214 | 'set decorator', 215 | 'silversmith', 216 | 'teacher', 217 | 'auto mechanic', 218 | 'beader', 219 | 'bobbin boy', 220 | 'clerk of the chapel', 221 | 'filling station attendant', 222 | 'foreman', 223 | 'maintenance engineering', 224 | 'mechanic', 225 | 'miller', 226 | 'moldmaker', 227 | 'panel beater', 228 | 'patternmaker', 229 | 'plant operator', 230 | 'plumber', 231 | 'sawfiler', 232 | 'shop foreman', 233 | 'soaper', 234 | 'stationary engineer', 235 | 'wheelwright', 236 | 'woodworkers', 237 | ] 238 | 239 | export const randomTitle = () => 240 | `${ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]} ${NOUNS[Math.floor(Math.random() * NOUNS.length)]}` 241 | -------------------------------------------------------------------------------- /img/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icrdr/react-gantt-antd/0b4e12512d557e84d5e262d3fd23e1fb5982c088/img/1.jpg -------------------------------------------------------------------------------- /jestSetup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | import { configure } from 'enzyme' 4 | import Adapter from 'enzyme-adapter-react-16' 5 | 6 | configure({ adapter: new Adapter() }) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gantt-antd", 3 | "version": "1.0.8", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib coverage", 8 | "build:js": "babel src/ -d lib/", 9 | "watch:js": "babel -w src/ -d lib/", 10 | "build:css": "node-sass src/scss/style.scss lib/css/style.css", 11 | "watch:css": "node-sass -w src/scss/style.scss lib/css/style.css", 12 | "watch": "npm run watch:js & npm run watch:css", 13 | "build": "npm run clean && npm run build:js && npm run build:css", 14 | "test": "npm run lint && npm run unit", 15 | "lint": "npm run lint:prettier && npm run lint:js", 16 | "lint:js": "eslint . --ext .js,.jsx", 17 | "lint:js:fix": "eslint . --ext .js,.jsx --fix", 18 | "lint:prettier": "prettier --list-different \"{e2e,src}/**/*.{js,jsx}\"", 19 | "lint:prettier:fix": "prettier --write \"{e2e,src}/**/*.{js,jsx}\"", 20 | "unit": "jest", 21 | "coverage": "jest --coverage --collectCoverageFrom='**/*.{js,jsx}'", 22 | "prepublish": "npm run clean && npm run build", 23 | "demo:deploy": "./demo/ops/deploy.sh" 24 | }, 25 | "keywords": [ 26 | "timeline", 27 | "schedule", 28 | "history", 29 | "react", 30 | "gantt", 31 | "horizontal", 32 | "library", 33 | "scroll", 34 | "scss", 35 | "sass", 36 | "tracks", 37 | "time" 38 | ], 39 | "repository": "", 40 | "author": "icrdr", 41 | "license": "MIT", 42 | "dependencies": { 43 | "prop-types": "^15.7.2" 44 | }, 45 | "files": [ 46 | "src", 47 | "lib" 48 | ], 49 | "devDependencies": { 50 | "@babel/cli": "^7.0.0", 51 | "@babel/core": "^7.0.0", 52 | "@babel/plugin-proposal-class-properties": "^7.4.4", 53 | "@babel/preset-env": "^7.0.0", 54 | "@babel/preset-react": "^7.0.0", 55 | "babel-eslint": "^10.0.1", 56 | "babel-jest": "^24.8.0", 57 | "babel-preset-env": "^1.7.0", 58 | "babel-preset-react": "^6.23.0", 59 | "enzyme": "^3.10.0", 60 | "enzyme-adapter-react-16": "^1.14.0", 61 | "eslint": "^5.16.0", 62 | "eslint-config-airbnb": "^17.1.0", 63 | "eslint-config-prettier": "^4.3.0", 64 | "eslint-plugin-import": "^2.17.3", 65 | "eslint-plugin-jsx-a11y": "^6.2.1", 66 | "eslint-plugin-react": "^7.13.0", 67 | "jest": "^24.8.0", 68 | "node-sass": "^4.12.0", 69 | "prettier": "^1.18.2", 70 | "react": "^16.8.6", 71 | "react-addons-test-utils": "^15.4.2", 72 | "react-dom": "^16.8.6", 73 | "rimraf": "^2.6.3" 74 | }, 75 | "peerDependencies": { 76 | "react": ">=16", 77 | "react-dom": ">=16" 78 | }, 79 | "jest": { 80 | "rootDir": "./src", 81 | "resetMocks": true, 82 | "resetModules": true, 83 | "moduleDirectories": [ 84 | "node_modules", 85 | "src" 86 | ], 87 | "coveragePathIgnorePatterns": [ 88 | "/node_modules/", 89 | "/utils/raf.js", 90 | "/utils/events.js", 91 | "/utils/computedStyle.js", 92 | "/propTypes.js" 93 | ], 94 | "setupFiles": [ 95 | "../jestSetup.js" 96 | ] 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/Layout.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext, useRef, useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { globalContext } from '../index' 5 | import ProjectKeys from './Sidebar/ProjectKeys' 6 | import Timebar from './Timeline/Timebar' 7 | import NowMarker from './Timeline/Now' 8 | import PointerMarker from './Timeline/Pointer' 9 | import getMouseX from '../utils/getMouseX' 10 | import Projects from './Timeline/Projects' 11 | import getGrid from '../utils/getGrid' 12 | import useEvent from '../hooks/useEvent' 13 | 14 | const noop = () => { } 15 | export const stickyContext = React.createContext(); 16 | 17 | const Layout = ({ enableSticky, scrollToNow, timebar, sidebarWidth, projects }) => { 18 | const { now, time } = useContext(globalContext) 19 | const refTimeline = useRef(null) 20 | const refScroll = useRef(null) 21 | const refTimebar = useRef(null) 22 | 23 | const grid = getGrid(timebar) 24 | const [isSticky, setSticky] = useState(false) 25 | const [hasShadow, setShadow] = useState(true) 26 | const [pointerDate, setPointerDate] = useState(null) 27 | const [pointerVisible, setPointerVisible] = useState(false) 28 | const [pointerHighlighted, setPointerHighlighted] = useState(false) 29 | 30 | let headerHeight = 0 31 | if (refTimebar.current) { 32 | headerHeight = refTimebar.current.offsetHeight 33 | } 34 | 35 | useEffect(() => { 36 | if (isSticky && refScroll.current && refTimeline.current) { 37 | refScroll.current.scrollLeft = refTimeline.current.scrollLeft 38 | } 39 | }, [isSticky]) 40 | 41 | useEffect(() => { 42 | if (scrollToNow && refTimeline.current) { 43 | refTimeline.current.scrollLeft = time.toX(now) - 0.5 * refTimeline.current.offsetWidth 44 | } 45 | }, [refTimeline.current]) 46 | 47 | 48 | const handleScroll = useCallback(() => { 49 | if (refTimeline.current) { 50 | const { top, bottom } = refTimeline.current.getBoundingClientRect() 51 | setSticky(top <= 0 && bottom >= headerHeight) 52 | 53 | if (refTimeline.current.scrollLeft === 0) { 54 | setShadow(false) 55 | } else { 56 | setShadow(true) 57 | } 58 | } 59 | }) 60 | 61 | if (enableSticky) { 62 | useEvent('scroll', handleScroll) 63 | } 64 | 65 | const handleMouseMove = e => { 66 | setPointerDate(time.fromX(getMouseX(e))) 67 | } 68 | 69 | const handleMouseLeave = () => { 70 | setPointerHighlighted(false) 71 | } 72 | 73 | const handleMouseEnter = () => { 74 | setPointerHighlighted(true) 75 | setPointerVisible(true) 76 | } 77 | 78 | const handleScrollBody = () => { 79 | if (refTimeline.current && refScroll.current) { 80 | refScroll.current.scrollLeft = refTimeline.current.scrollLeft 81 | } 82 | 83 | } 84 | 85 | 86 | const handleScrollHeader = () => { 87 | if (refTimeline.current && refScroll.current) { 88 | refTimeline.current.scrollLeft = refScroll.current.scrollLeft 89 | } 90 | } 91 | 92 | return ( 93 |
94 |
95 |
96 |
97 |
101 | {timebar.slice(1, 3).map(({ id, title }) => ( 102 |
103 | {title} 104 |
105 | ))} 106 |
107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | {now && } 117 | {pointerDate && ( 118 | 119 | )} 120 |
126 |
130 |
131 |
132 | 133 |
134 |
135 |
136 |
137 |
138 |
139 | {grid.map(({ id, start, end }) => ( 140 |
141 | ))} 142 |
143 | 144 |
145 |
146 |
147 |
148 |
149 | ) 150 | } 151 | 152 | Layout.propTypes = { 153 | enableSticky: PropTypes.bool.isRequired, 154 | scrollToNow: PropTypes.bool, 155 | } 156 | 157 | export default Layout 158 | -------------------------------------------------------------------------------- /src/components/Sidebar/ProjectKey.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import ProjectKeys from './ProjectKeys' 5 | import { globalContext } from '../../index' 6 | const ProjectKey = ({ project }) => { 7 | const { toggleProjectOpen } = useContext(globalContext) 8 | const { title, projects, isOpen, sideComponent } = project 9 | 10 | const isExpandable = isOpen !== undefined 11 | 12 | const buildSideComponent = () => { 13 | if (sideComponent) { 14 | return React.cloneTask(sideComponent) 15 | } 16 | return null 17 | } 18 | 19 | return ( 20 |
  • 21 |
    22 | {isExpandable && ( 23 |
    toggleProjectOpen(project)} 26 | > 27 | {!isOpen ? ( 28 | 29 | ) : ( 30 | 31 | )} 32 | 33 |
    34 | )} 35 | {title} 36 | {buildSideComponent()} 37 |
    38 | {isOpen && projects && projects.length > 0 && } 39 |
  • 40 | ) 41 | } 42 | 43 | ProjectKey.propTypes = { 44 | project: PropTypes.shape({ 45 | title: PropTypes.oneOfType([ 46 | PropTypes.element, 47 | PropTypes.string 48 | ]).isRequired, 49 | projects: PropTypes.arrayOf(PropTypes.shape({})), 50 | isOpen: PropTypes.bool, 51 | }), 52 | } 53 | 54 | 55 | export default ProjectKey 56 | -------------------------------------------------------------------------------- /src/components/Sidebar/ProjectKeys.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import ProjectKey from './ProjectKey' 5 | 6 | const ProjectKeys = ({ projects }) => { 7 | return ( 8 |
      9 | {projects.map(project => ( 10 | 11 | ))} 12 |
    13 | ) 14 | } 15 | 16 | ProjectKeys.propTypes = { 17 | projects: PropTypes.arrayOf(PropTypes.shape({})), 18 | } 19 | 20 | export default ProjectKeys 21 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Marker = ({ x, modifier, children, visible, highlighted }) => ( 5 |
    12 |
    13 |
    {children}
    14 |
    15 |
    16 | ) 17 | 18 | Marker.propTypes = { 19 | x: PropTypes.number.isRequired, 20 | modifier: PropTypes.string.isRequired, 21 | visible: PropTypes.bool, 22 | highlighted: PropTypes.bool, 23 | children: PropTypes.node, 24 | } 25 | 26 | export default Marker 27 | -------------------------------------------------------------------------------- /src/components/Timeline/Now.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Marker from './Marker' 5 | import { globalContext } from '../../index' 6 | 7 | 8 | const NowMarker = ({ visible }) => { 9 | const { time, now } = useContext(globalContext) 10 | return ( 11 | 12 |
    13 |
    此时
    14 |
    15 |
    16 | ) 17 | } 18 | 19 | NowMarker.propTypes = { 20 | visible: PropTypes.bool.isRequired, 21 | } 22 | 23 | export default NowMarker 24 | -------------------------------------------------------------------------------- /src/components/Timeline/Pointer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { getDayMonth } from '../../utils/formatDate' 5 | import { globalContext } from '../../index' 6 | import Marker from './Marker' 7 | 8 | const PointerMarker = ({ date, visible, highlighted }) => { 9 | const { time } = useContext(globalContext) 10 | return ( 11 | 12 |
    13 |
    14 | {getDayMonth(date)} 15 |
    16 |
    17 |
    18 | ) 19 | } 20 | 21 | PointerMarker.propTypes = { 22 | date: PropTypes.instanceOf(Date).isRequired, 23 | visible: PropTypes.bool, 24 | highlighted: PropTypes.bool, 25 | } 26 | 27 | export default PointerMarker 28 | -------------------------------------------------------------------------------- /src/components/Timeline/Project.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Projects from './Projects' 5 | import Task from './Task' 6 | 7 | const Project = ({ tasks, isOpen, projects }) => { 8 | return ( 9 |
    10 |
    11 | {tasks 12 | .filter(({ start, end }) => end > start) 13 | .map((task, i) => ( 14 | 15 | ))} 16 |
    17 | {isOpen && projects && projects.length > 0 && } 18 |
    19 | ) 20 | } 21 | 22 | Project.propTypes = { 23 | isOpen: PropTypes.bool, 24 | tasks: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 25 | projects: PropTypes.arrayOf(PropTypes.shape({})), 26 | } 27 | 28 | export default Project 29 | -------------------------------------------------------------------------------- /src/components/Timeline/Projects.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Project from './Project' 4 | 5 | const Projects = ({ projects }) => { 6 | return ( 7 |
    8 | {projects.map(({ id, tasks, isOpen, projects: children }) => ( 9 | 10 | ))} 11 |
    12 | ) 13 | } 14 | 15 | Projects.propTypes = { 16 | projects: PropTypes.arrayOf(PropTypes.shape({})), 17 | } 18 | 19 | export default Projects 20 | -------------------------------------------------------------------------------- /src/components/Timeline/Task.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-task-interactions */ 2 | import React, { useContext } from 'react' 3 | import PropTypes from 'prop-types' 4 | 5 | import TaskBasic from './TaskBasic' 6 | import { globalContext } from '../../index' 7 | const Task = ({ index, style, styleBase, title, start, end, classes, dataSet, tooltip }) => { 8 | 9 | const { now, time, clickTask } = useContext(globalContext) 10 | const handleClick = () => { 11 | clickTask({ index, style, styleBase, title, start, end, classes, dataSet, tooltip }) 12 | } 13 | const taskStyle = { 14 | ...time.toStyleLeftAndWidth(start, end), 15 | ...(clickTask ? { cursor: 'pointer' } : {}), 16 | } 17 | 18 | return ( 19 |
    23 |
    31 |
    39 | 47 |
    48 | ) 49 | } 50 | 51 | Task.propTypes = { 52 | styleBase: PropTypes.shape({}), 53 | style: PropTypes.shape({}), 54 | classes: PropTypes.arrayOf(PropTypes.string.isRequired), 55 | dataSet: PropTypes.shape({}), 56 | title: PropTypes.oneOfType([ 57 | PropTypes.element, 58 | PropTypes.string 59 | ]).isRequired, 60 | start: PropTypes.instanceOf(Date).isRequired, 61 | end: PropTypes.instanceOf(Date).isRequired, 62 | tooltip: PropTypes.string, 63 | clickTask: PropTypes.func, 64 | } 65 | 66 | Task.defaultTypes = { 67 | clickTask: undefined, 68 | } 69 | 70 | export default Task 71 | -------------------------------------------------------------------------------- /src/components/Timeline/TaskBasic.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { getDayMonth } from '../../utils/formatDate' 4 | import createClasses from '../../utils/classes' 5 | 6 | const buildDataAttributes = (attributes = {}) => { 7 | const value = {} 8 | Object.keys(attributes).forEach(name => { 9 | value[`data-${name}`] = attributes[name] 10 | }) 11 | return value 12 | } 13 | 14 | const Basic = ({ title, start, end, classes, dataSet, tooltip }) => ( 15 |
    16 | 19 |
    20 | {tooltip ? ( 21 | // eslint-disable-next-line react/no-danger 22 |
    ') }} /> 23 | ) : ( 24 |
    25 |
    {title}
    26 |
    27 | 起始 {getDayMonth(start)} 28 |
    29 |
    30 | 终止 {getDayMonth(end)} 31 |
    32 |
    33 | )} 34 |
    35 |
    36 | ) 37 | 38 | Basic.propTypes = { 39 | title: PropTypes.string.isRequired, 40 | start: PropTypes.instanceOf(Date).isRequired, 41 | end: PropTypes.instanceOf(Date).isRequired, 42 | style: PropTypes.shape({}), 43 | classes: PropTypes.arrayOf(PropTypes.string.isRequired), 44 | dataSet: PropTypes.shape({}), 45 | tooltip: PropTypes.string, 46 | } 47 | 48 | export default Basic 49 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { globalContext } from '../../index' 4 | 5 | const Cell = ({ title, start, end, style }) => { 6 | const { time } = useContext(globalContext) 7 | return ( 8 |
    13 | {title} 14 |
    15 | ) 16 | } 17 | 18 | const Row = ({ cells, style }) => { 19 | const { time } = useContext(globalContext) 20 | let props = {} 21 | if (time.timelineWidth / cells.length < 22) { 22 | props = { 23 | title: '' 24 | } 25 | } 26 | return ( 27 |
    28 | {cells.map(cell => ( 29 | 30 | ))} 31 |
    32 | ) 33 | } 34 | 35 | const Timebar = ({ rows }) => { 36 | const { time } = useContext(globalContext) 37 | return ( 38 |
    39 | {rows.map(({ id, title, cells, style }) => ( 40 | 41 | ))} 42 |
    43 | ) 44 | } 45 | 46 | Timebar.propTypes = { 47 | rows: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 48 | } 49 | 50 | export default Timebar 51 | -------------------------------------------------------------------------------- /src/hooks/useEvent.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export default function useEvent(eventName, handler, element = window) { 4 | // Create a ref that stores handler 5 | const savedHandler = useRef(); 6 | 7 | // Update ref.current value if handler changes. 8 | // This allows our effect below to always get latest handler ... 9 | // ... without us needing to pass it in effect deps array ... 10 | // ... and potentially cause effect to re-run every render. 11 | useEffect(() => { 12 | savedHandler.current = handler; 13 | }, [handler]); 14 | 15 | useEffect( 16 | () => { 17 | // Make sure element supports addEventListener 18 | // On 19 | const isSupported = element && element.addEventListener; 20 | if (!isSupported) return; 21 | 22 | // Create event listener that calls handler function stored in ref 23 | const eventListener = event => savedHandler.current(event); 24 | 25 | // Add event listener 26 | element.addEventListener(eventName, eventListener); 27 | 28 | // Remove event listener on cleanup 29 | return () => { 30 | element.removeEventListener(eventName, eventListener); 31 | }; 32 | }, 33 | [eventName, element] // Re-run if eventName or element changes 34 | ); 35 | }; -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | import Layout from './components/Layout' 4 | import createTime from './utils/time' 5 | import useEvent from './hooks/useEvent' 6 | export const globalContext = React.createContext(); 7 | 8 | function Gantt({ 9 | start, end, 10 | zoom = 1, 11 | projects = [], 12 | now = new Date(), 13 | sidebarWidth = 120, 14 | minWidth = 400, 15 | scrollToNow = true, 16 | enableSticky = true, 17 | clickTask, 18 | }) { 19 | const [time, setTime] = useState(createTime(start, end, zoom, 0, minWidth)) 20 | const [_projects, setProjects] = useState(projects) 21 | 22 | const toggleProjectOpen = project => { 23 | setProjects(prevState => { 24 | for (const _project of prevState) { 25 | if (_project.id === project.id) { 26 | _project.isOpen = !project.isOpen 27 | } 28 | } 29 | return [...prevState] 30 | }) 31 | } 32 | 33 | const gantt = useRef(null) 34 | 35 | const buildMonthCells = () => { 36 | const v = [] 37 | function getMonthAdd(y, m) { 38 | while (m >= 12) { 39 | m -= 12 40 | y += 1 41 | } 42 | return new Date(`${y}-${m + 1}-1 0:0:0`) 43 | } 44 | const month_count = end.getMonth() - start.getMonth() + (12 * (end.getFullYear() - start.getFullYear())) + 1 45 | for (let i = 0; i < month_count; i += 1) { 46 | 47 | const start_date = getMonthAdd(start.getFullYear(), start.getMonth() + i) 48 | const end_date = getMonthAdd(start.getFullYear(), start.getMonth() + i + 1) 49 | v.push({ 50 | id: `m${i}`, 51 | title: `${(start.getMonth() + i) % 12 + 1}月`, 52 | start: start_date, 53 | end: end_date, 54 | }) 55 | } 56 | return v 57 | } 58 | const buildDayCells = () => { 59 | const v = [] 60 | const start_floor = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0) 61 | const day_count = Math.floor((end - start) / (1000 * 60 * 60 * 24)) + 1 62 | for (let i = 0; i < day_count; i += 1) { 63 | const start_date = new Date(start_floor.getTime() + i * 1000 * 60 * 60 * 24) 64 | const end_date = new Date(start_floor.getTime() + (i + 1) * 1000 * 60 * 60 * 24) 65 | v.push({ 66 | id: `d${i}`, 67 | title: `${start_date.getDate()}`, 68 | start: start_date, 69 | end: end_date, 70 | style: { 71 | backgroundColor: start_date.getDay() === 0 ? '#1890ff' : '', 72 | color: start_date.getDay() === 0 ? '#fff' : '' 73 | } 74 | }) 75 | } 76 | return v 77 | } 78 | const buildWeekCells = () => { 79 | const v = [] 80 | const start_floor = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0) 81 | const week_count = Math.floor((end - start) / (1000 * 60 * 60 * 24 * 7)) + 2 82 | for (let i = 0; i < week_count; i += 1) { 83 | const start_date = new Date(start_floor.getTime() + (i * 7 - start_floor.getDay()) * 1000 * 60 * 60 * 24) 84 | const end_date = new Date(start_floor.getTime() + ((i + 1) * 7 - start_floor.getDay()) * 1000 * 60 * 60 * 24) 85 | v.push({ 86 | id: `w${i}`, 87 | title: ``, 88 | start: start_date, 89 | end: end_date 90 | }) 91 | } 92 | return v 93 | } 94 | 95 | const timebar = [ 96 | { 97 | id: 'weeks', 98 | title: '', 99 | cells: buildWeekCells(), 100 | useAsGrid: true, 101 | }, 102 | { 103 | id: 'months', 104 | title: '月份', 105 | cells: buildMonthCells(), 106 | 107 | }, 108 | { 109 | id: 'days', 110 | title: '日期', 111 | cells: buildDayCells(), 112 | } 113 | ] 114 | 115 | useEffect(() => { 116 | if (gantt.current) { 117 | setTime(createTime({ 118 | start, end, zoom, 119 | viewportWidth: gantt.current.offsetWidth - sidebarWidth, 120 | minWidth: minWidth - sidebarWidth 121 | })) 122 | } 123 | }, [zoom, start, end]) 124 | 125 | const handleResize = useCallback(() => { 126 | if (gantt.current) { 127 | setTime(createTime({ 128 | start, end, zoom, 129 | viewportWidth: gantt.current.offsetWidth - sidebarWidth, 130 | minWidth: minWidth - sidebarWidth 131 | })) 132 | } 133 | }) 134 | 135 | useEvent('resize', handleResize) 136 | 137 | return ( 138 |
    139 | 145 | 152 | 153 |
    154 | ) 155 | } 156 | 157 | Gantt.propTypes = { 158 | start: PropTypes.instanceOf(Date).isRequired, 159 | end: PropTypes.instanceOf(Date).isRequired, 160 | now: PropTypes.instanceOf(Date), 161 | zoom: PropTypes.number.isRequired, 162 | projects: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 163 | minWidth: PropTypes.number, 164 | sideWidth: PropTypes.number, 165 | clickTask: PropTypes.func, 166 | enableSticky: PropTypes.bool, 167 | scrollToNow: PropTypes.bool, 168 | } 169 | 170 | export default Gantt 171 | -------------------------------------------------------------------------------- /src/scss/_utils.scss: -------------------------------------------------------------------------------- 1 | .rt-visually-hidden { 2 | position: absolute; 3 | overflow: hidden; 4 | clip: rect(0 0 0 0); 5 | height: 1px; width: 1px; 6 | margin: -1px; padding: 0; border: 0; 7 | } -------------------------------------------------------------------------------- /src/scss/components/_controls.scss: -------------------------------------------------------------------------------- 1 | .rt-controls { 2 | display: inline-block; 3 | padding: 8px; 4 | margin: 0 0 $react-timelines-spacing 0; 5 | background-color: #fff; 6 | } 7 | 8 | .rt-controls__button { 9 | display: inline-block; 10 | width: $react-timelines-button-size; 11 | height: $react-timelines-button-size; 12 | overflow: hidden; 13 | background-color: $react-timelines-button-background-color; 14 | color: transparent; 15 | white-space: nowrap; 16 | padding: $react-timelines-spacing; 17 | outline: none; 18 | 19 | &:last-child { 20 | margin-right: 0; 21 | } 22 | 23 | &:hover { 24 | background-color: $react-timelines-button-background-color-hover; 25 | } 26 | 27 | &:focus, 28 | &:active { 29 | background-color: $react-timelines-button-background-color-hover; 30 | } 31 | } 32 | 33 | .rt-controls__button[disabled] { 34 | opacity: 0.5; 35 | } 36 | 37 | .rt-controls__button--toggle { 38 | @media (min-width: $react-timelines-auto-open-breakpoint) { 39 | display: none; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/scss/components/_grid.scss: -------------------------------------------------------------------------------- 1 | .rt-grid, 2 | .rt-grid__cell { 3 | position: absolute; 4 | top: 0; 5 | bottom: 0; 6 | } 7 | 8 | .rt-grid { 9 | left: 0; 10 | right: 0; 11 | } 12 | 13 | .rt-grid__cell { 14 | background: #fff; 15 | border-left: 1px solid $react-timelines-keyline-color; 16 | } 17 | -------------------------------------------------------------------------------- /src/scss/components/_layout.scss: -------------------------------------------------------------------------------- 1 | 2 | .rt-layout__side { 3 | position: relative; 4 | z-index: 2; 5 | display: inline-block; 6 | vertical-align: top; 7 | } 8 | 9 | .rt-layout__main { 10 | display: inline-block; 11 | vertical-align: top; 12 | } 13 | 14 | .rt-layout__timeline { 15 | overflow-x: auto; 16 | } 17 | -------------------------------------------------------------------------------- /src/scss/components/_marker.scss: -------------------------------------------------------------------------------- 1 | .rt-marker { 2 | position: absolute; 3 | z-index: 2; 4 | top: $react-timelines-header-row-height; 5 | bottom: 0; 6 | margin-left: -($react-timelines-marker-line-width / 2); 7 | border-left: $react-timelines-marker-line-width solid; 8 | opacity: 0; 9 | pointer-events: none; 10 | } 11 | 12 | .rt-marker.rt-is-visible { 13 | opacity: 1; 14 | } 15 | 16 | 17 | .rt-marker--now { 18 | color: $react-timelines-marker-now-background-color; 19 | border-color: rgba( 20 | $react-timelines-marker-now-background-color, 21 | $react-timelines-marker-line-transparency 22 | ); 23 | } 24 | 25 | .rt-marker--pointer { 26 | color: $react-timelines-marker-pointer-background-color; 27 | border-color: rgba( 28 | $react-timelines-marker-pointer-background-color, 29 | $react-timelines-marker-line-transparency 30 | ); 31 | } 32 | 33 | .rt-marker__label { 34 | position: absolute; 35 | bottom: 100%; 36 | left: 50%; 37 | display: table; 38 | min-width: 70px; 39 | height: $react-timelines-header-row-height; 40 | padding: 0 10px; 41 | line-height: 1.1; 42 | text-align: center; 43 | background: currentColor; 44 | transform: translateX(-50%); 45 | font-size: 14px; 46 | 47 | &::before { 48 | $size: 6px; 49 | 50 | position: absolute; 51 | top: 100%; 52 | left: 50%; 53 | margin-left: -$size; 54 | transform: translateX(-($react-timelines-marker-line-width / 2)); 55 | border-left: $size solid transparent; 56 | border-right: $size solid transparent; 57 | border-top: $size solid currentColor; 58 | content: ' '; 59 | } 60 | } 61 | 62 | .rt-marker__content { 63 | display: table-cell; 64 | vertical-align: middle; 65 | white-space: nowrap; 66 | color: white; 67 | } 68 | -------------------------------------------------------------------------------- /src/scss/components/_project-key.scss: -------------------------------------------------------------------------------- 1 | .rt-project-key__entry { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | 6 | height: $react-timelines-project-height + $react-timelines-border-width; 7 | line-height: $react-timelines-project-height; 8 | text-align: left; 9 | border-bottom: $react-timelines-border-width solid $react-timelines-sidebar-separator-color; 10 | } 11 | 12 | .rt-icon { 13 | color: $react-feather-color; 14 | } 15 | 16 | .rt-project-keys > .rt-project-key > 17 | .rt-project-key__entry { 18 | padding-left: $react-timelines-sidebar-key-indent-width; 19 | } 20 | 21 | .rt-project-keys > .rt-project-key > 22 | .rt-project-keys > .rt-project-key > 23 | .rt-project-key__entry { 24 | padding-left: $react-timelines-sidebar-key-indent-width * 2; 25 | } 26 | 27 | .rt-project-keys > .rt-project-key > 28 | .rt-project-keys > .rt-project-key > 29 | .rt-project-keys > .rt-project-key > 30 | .rt-project-key__entry { 31 | padding-left: $react-timelines-sidebar-key-indent-width * 3; 32 | } 33 | 34 | .rt-project-keys > .rt-project-key > 35 | .rt-project-keys > .rt-project-key > 36 | .rt-project-keys > .rt-project-key > 37 | .rt-project-keys > .rt-project-key > 38 | .rt-project-key__entry { 39 | padding-left: $react-timelines-sidebar-key-indent-width * 4; 40 | } 41 | 42 | .rt-project-keys > .rt-project-key > 43 | .rt-project-keys > .rt-project-key > 44 | .rt-project-keys > .rt-project-key > 45 | .rt-project-keys > .rt-project-key > 46 | .rt-project-keys > .rt-project-key > 47 | .rt-project-key__entry { 48 | padding-left: $react-timelines-sidebar-key-indent-width * 5; 49 | } 50 | 51 | .rt-project-key__title { 52 | flex: 1; 53 | white-space: nowrap; 54 | text-overflow: ellipsis; 55 | overflow: hidden; 56 | } 57 | 58 | .rt-project-key__side-button { 59 | height: $react-timelines-project-height; 60 | width: $react-timelines-project-height; 61 | color: transparent; 62 | background: transparent; 63 | 64 | &:hover, 65 | &:focus { 66 | background: $react-timelines-sidebar-key-icon-hover-color; 67 | color: transparent; 68 | } 69 | 70 | &::before { 71 | position: absolute; 72 | width: $react-timelines-sidebar-key-icon-size; 73 | height: $react-timelines-sidebar-key-icon-size; 74 | margin-top: -$react-timelines-sidebar-key-icon-size / 2; 75 | margin-left: -$react-timelines-sidebar-key-icon-size / 2; 76 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDQ4Mi4xMzYgNDgyLjEzNSI+PHBhdGggZmlsbD0iIzc2NzY3NiIgZD0iTTQ1NS40ODIgMTk4LjE4NEwzMjYuODMgMzI2LjgzMmMtMzUuNTM2IDM1LjU0LTkzLjExIDM1LjU0LTEyOC42NDcgMGwtNDIuODgtNDIuODg2IDQyLjg4LTQyLjg3NiA0Mi44ODQgNDIuODc2YzExLjg0NSAxMS44MjIgMzEuMDY0IDExLjg0NiA0Mi44ODYgMGwxMjguNjQ0LTEyOC42NDNjMTEuODE2LTExLjgzIDExLjgxNi0zMS4wNjYgMC00Mi45bC00Mi44OC00Mi44OGMtMTEuODIzLTExLjgxNS0zMS4wNjUtMTEuODE1LTQyLjg4OCAwbC00NS45MyA0NS45MzVjLTIxLjI5LTEyLjUzLTQ1LjQ5LTE3LjkwNS02OS40NS0xNi4yOWw3Mi41LTcyLjUyN2MzNS41MzYtMzUuNTIgOTMuMTM3LTM1LjUyIDEyOC42NDUgMGw0Mi44ODYgNDIuODg0YzM1LjUzNiAzNS41MjMgMzUuNTM2IDkzLjE0IDAgMTI4LjY2MnpNMjAxLjIwNiAzNjYuNjk4bC00NS45MDMgNDUuOWMtMTEuODQ1IDExLjg0Ni0zMS4wNjQgMTEuODE3LTQyLjg4IDBsLTQyLjg4NS00Mi44OGMtMTEuODQ1LTExLjgyMi0xMS44NDUtMzEuMDQyIDAtNDIuODg3bDEyOC42NDYtMTI4LjY0NWMxMS44Mi0xMS44MTQgMzEuMDctMTEuODE0IDQyLjg4NCAwbDQyLjg4NiA0Mi44ODYgNDIuODc2LTQyLjg4Ni00Mi44NzYtNDIuODhjLTM1LjU0LTM1LjUyMi05My4xMTMtMzUuNTIyLTEyOC42NSAwbC0xMjguNjUgMTI4LjY0Yy0zNS41MzcgMzUuNTQ2LTM1LjUzNyA5My4xNDcgMCAxMjguNjUzTDY5LjU0IDQ1NS40OGMzNS41MSAzNS41NCA5My4xMSAzNS41NCAxMjguNjQ2IDBsNzIuNDk2LTcyLjVjLTIzLjk1NiAxLjU5OC00OC4wOTItMy43ODMtNjkuNDc0LTE2LjI4MnoiLz48L3N2Zz4='); 77 | content: ' '; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/scss/components/_project-keys.scss: -------------------------------------------------------------------------------- 1 | .rt-project-keys { 2 | margin: 0; 3 | padding-left: 0; 4 | list-style: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/scss/components/_project.scss: -------------------------------------------------------------------------------- 1 | .rt-project {} 2 | 3 | .rt-project__tasks { 4 | position: relative; 5 | height: $react-timelines-project-height + $react-timelines-border-width; 6 | border-bottom: $react-timelines-border-width solid $react-timelines-keyline-color; 7 | } 8 | 9 | .rt-project__task { 10 | position: absolute; 11 | height: $react-timelines-project-height - 2 * $react-timelines-task-spacing; 12 | top: $react-timelines-task-spacing; 13 | } 14 | -------------------------------------------------------------------------------- /src/scss/components/_projects.scss: -------------------------------------------------------------------------------- 1 | .rt-projects {} 2 | -------------------------------------------------------------------------------- /src/scss/components/_sidebar.scss: -------------------------------------------------------------------------------- 1 | .rt-sidebar { 2 | background-color: $react-timelines-sidebar-background-color; 3 | border-right: 1px solid $react-timelines-sidebar-separator-color 4 | } 5 | 6 | .rt-sidebar.rt-sidebar-shadow { 7 | box-shadow: 10px 0 10px -5px rgba(12, 12, 12, 0.1); 8 | } 9 | 10 | .rt-sidebar__header { 11 | background-color: $react-timelines-timebar-cell-background-color; 12 | font-weight: 500; 13 | } 14 | 15 | .rt-sidebar__header.rt-is-sticky { 16 | position: fixed; 17 | top: 0; 18 | z-index: 2; 19 | direction: rtl; 20 | margin-left: ($react-timelines-sidebar-width - $react-timelines-sidebar-closed-offset); 21 | border-right: 1px solid $react-timelines-sidebar-separator-color; 22 | @media (min-width: $react-timelines-auto-open-breakpoint) { 23 | margin-left: 0; 24 | direction: ltr; 25 | } 26 | } 27 | 28 | .rt-sidebar__header.rt-is-sticky { 29 | margin-left: 0; 30 | direction: ltr; 31 | } 32 | 33 | .rt-sidebar__body {} 34 | -------------------------------------------------------------------------------- /src/scss/components/_task.scss: -------------------------------------------------------------------------------- 1 | .rt-task { 2 | $height: $react-timelines-project-height - 2 * $react-timelines-task-spacing; 3 | 4 | position: relative; 5 | height: $height; 6 | line-height: $height; 7 | text-align: center; 8 | } 9 | 10 | .rt-task__content { 11 | padding: 0 10px; 12 | overflow: hidden; 13 | white-space: nowrap; 14 | text-overflow: ellipsis; 15 | } 16 | 17 | .rt-task__tooltip { 18 | position: absolute; 19 | bottom: 100%; 20 | left: 50%; 21 | z-index: 2; 22 | padding: 10px; 23 | line-height: 1.3; 24 | white-space: nowrap; 25 | text-align: left; 26 | background: $react-timelines-text-color; 27 | color: white; 28 | transform: translateX(-50%) scale(0); 29 | pointer-events: none; 30 | 31 | &::before { 32 | $size: 6px; 33 | position: absolute; 34 | top: 100%; 35 | left: 50%; 36 | border-top: $size solid $react-timelines-text-color; 37 | border-right: $size solid transparent; 38 | border-left: $size solid transparent; 39 | transform: translateX(-50%); 40 | content: ' '; 41 | } 42 | } 43 | 44 | .rt-task:hover > .rt-task__tooltip, 45 | .rt-task:focus > .rt-task__tooltip { 46 | $delay: 0.3s; 47 | transform: translateX(-50%) scale(1); 48 | transition: transform 0s $delay; 49 | } 50 | -------------------------------------------------------------------------------- /src/scss/components/_timebar-key.scss: -------------------------------------------------------------------------------- 1 | .rt-timebar-key { 2 | height: $react-timelines-header-row-height + $react-timelines-border-width; 3 | padding-right: $react-timelines-sidebar-key-indent-width; 4 | line-height: $react-timelines-header-row-height; 5 | text-align: right; 6 | border-bottom: 1px solid $react-timelines-sidebar-separator-color; 7 | 8 | &:last-child { 9 | border-bottom-color: $react-timelines-header-separator-color; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/scss/components/_timebar.scss: -------------------------------------------------------------------------------- 1 | .rt-timebar { 2 | background-color: $react-timelines-timebar-cell-background-color; 3 | font-weight: 500; 4 | } 5 | 6 | .rt-timebar__row { 7 | position: relative; 8 | height: $react-timelines-header-row-height + $react-timelines-border-width; 9 | overflow: hidden; 10 | line-height: $react-timelines-header-row-height; 11 | border-bottom: $react-timelines-border-width solid $react-timelines-keyline-color; 12 | &:last-child { 13 | border-bottom-color: $react-timelines-header-separator-color; 14 | } 15 | } 16 | 17 | .rt-timebar__cell { 18 | position: absolute; 19 | text-align: center; 20 | background-color: $react-timelines-timebar-cell-background-color; 21 | border-left: 1px solid $react-timelines-keyline-color; 22 | text-overflow: ellipsis; 23 | overflow: hidden; 24 | white-space: nowrap; 25 | } 26 | -------------------------------------------------------------------------------- /src/scss/components/_timeline.scss: -------------------------------------------------------------------------------- 1 | .rt-timeline { 2 | position: relative; 3 | overflow: hidden; 4 | } 5 | 6 | .rt-timeline__header {} 7 | 8 | .rt-timeline__header-scroll { 9 | overflow-x: auto; 10 | 11 | &::-webkit-scrollbar { 12 | display: none; 13 | } 14 | } 15 | 16 | .rt-timeline__header.rt-is-sticky { 17 | position: fixed; 18 | top: 0; 19 | z-index: 1; 20 | overflow: hidden; 21 | } 22 | 23 | .rt-timeline__body { 24 | position: relative; 25 | background: white; 26 | } 27 | -------------------------------------------------------------------------------- /src/scss/style.scss: -------------------------------------------------------------------------------- 1 | // Common 2 | $react-timelines-spacing: 10px; 3 | $react-timelines-keyline-color: rgb(232, 232, 232) !default; 4 | $react-timelines-separator-dark-color: rgb(232, 232, 232) !default; 5 | 6 | $react-timelines-auto-open-breakpoint: 1000px !default; 7 | $react-timelines-font-family: sans-serif !default; 8 | $react-timelines-border-width: 1px !default; 9 | $react-timelines-text-color: #4c4c4c !default; 10 | 11 | // Header 12 | $react-timelines-header-row-height: 40px !default; 13 | $react-timelines-header-separator-color: $react-timelines-separator-dark-color !default; 14 | 15 | // Sidebar 16 | $react-timelines-sidebar-width: 240px !default; 17 | $react-timelines-sidebar-closed-offset: 40px; 18 | $react-timelines-sidebar-background-color: #fff !default; 19 | $react-timelines-sidebar-separator-color: $react-timelines-keyline-color !default; 20 | $react-timelines-sidebar-key-indent-width: 20px !default; 21 | $react-timelines-sidebar-key-icon-size: 16px !default; 22 | $react-timelines-sidebar-key-icon-hover-color: #eee !default; 23 | 24 | // Timebar 25 | $react-timelines-timebar-cell-background-color: #fafafa; 26 | 27 | // Project / Tasks 28 | $react-timelines-project-height: 60px !default; 29 | $react-timelines-task-spacing: $react-timelines-spacing !default; 30 | 31 | // Markers 32 | $react-timelines-marker-line-width: 2px !default; 33 | $react-timelines-marker-now-background-color: #bbb !default; 34 | $react-timelines-marker-pointer-background-color: #1890ff !default; 35 | $react-timelines-marker-line-transparency: 0.5 !default; 36 | 37 | // Controls 38 | $react-timelines-button-background-color: #fff !default; 39 | $react-timelines-button-background-color-hover: #f0f0f0 !default; 40 | $react-timelines-button-size: 44px !default; 41 | 42 | $react-feather-color: #1890ff !default; 43 | .rt { 44 | position: relative; 45 | z-index: 1; 46 | overflow: hidden; 47 | font-family: $react-timelines-font-family; 48 | color: $react-timelines-text-color; 49 | font-size: 14px; 50 | 51 | * { 52 | box-sizing: border-box; 53 | } 54 | } 55 | 56 | @import 'utils'; 57 | 58 | @import 'components/controls'; 59 | @import 'components/task'; 60 | @import 'components/grid'; 61 | @import 'components/layout'; 62 | @import 'components/marker'; 63 | @import 'components/sidebar'; 64 | @import 'components/timebar'; 65 | @import 'components/timebar-key'; 66 | @import 'components/timeline'; 67 | @import 'components/project'; 68 | @import 'components/projects'; 69 | @import 'components/project-key'; 70 | @import 'components/project-keys'; 71 | -------------------------------------------------------------------------------- /src/utils/__tests__/classes.js: -------------------------------------------------------------------------------- 1 | import classes from '../classes' 2 | 3 | describe('classes', () => { 4 | it('returns the base class', () => { 5 | expect(classes('foo')).toBe('foo') 6 | }) 7 | 8 | it('returns the base class plus additional class passed as string', () => { 9 | expect(classes('bar', 'hello')).toBe('bar hello') 10 | }) 11 | 12 | it('returns the base class plus additional class passed as array', () => { 13 | expect(classes('bar', ['hello'])).toBe('bar hello') 14 | expect(classes('foo', ['hello', 'world'])).toBe('foo hello world') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/utils/__tests__/formatDate.js: -------------------------------------------------------------------------------- 1 | import { getMonth, getDayMonth } from '../formatDate' 2 | 3 | describe('formatDate', () => { 4 | describe('getMonth', () => { 5 | it('returns the current month name for a given date', () => { 6 | expect(getMonth(new Date('2017-01-01'))).toEqual('Jan') 7 | expect(getMonth(new Date('2017-02-01'))).toEqual('Feb') 8 | expect(getMonth(new Date('2017-11-01'))).toEqual('Nov') 9 | }) 10 | }) 11 | 12 | describe('getDayMonth', () => { 13 | it('returns the current day and month', () => { 14 | expect(getDayMonth(new Date('2017-02-01'))).toEqual('1 Feb') 15 | expect(getDayMonth(new Date('2017-05-20'))).toEqual('20 May') 16 | expect(getDayMonth(new Date('2017-12-20'))).toEqual('20 Dec') 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/utils/__tests__/getGrid.js: -------------------------------------------------------------------------------- 1 | import getGrid from '../getGrid' 2 | 3 | describe('getGrid', () => { 4 | it('returns the cells from the first timebar row that has "useAsGrid" set to true', () => { 5 | const timebar = [ 6 | { 7 | cells: [{ id: 'row-1-cell-1' }], 8 | }, 9 | { 10 | useAsGrid: true, 11 | cells: [{ id: 'row-2-cell-1' }], 12 | }, 13 | { 14 | useAsGrid: true, 15 | cells: [{ id: 'row-3-cell-1' }], 16 | }, 17 | ] 18 | const actual = getGrid(timebar) 19 | const expected = [{ id: 'row-2-cell-1' }] 20 | expect(actual).toEqual(expected) 21 | }) 22 | 23 | it('returns "undefined" if none of the rows have "useAsGrid" set to true', () => { 24 | const timebar = [{ cells: [] }] 25 | const actual = getGrid(timebar) 26 | expect(actual).toEqual(undefined) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/utils/__tests__/getMouseX.js: -------------------------------------------------------------------------------- 1 | import getMouseX from '../getMouseX' 2 | 3 | describe('getMouseX', () => { 4 | it('gets mouse x position for a given event', () => { 5 | const event = { 6 | clientX: 200, 7 | currentTarget: { 8 | getBoundingClientRect: () => ({ left: 10 }), 9 | }, 10 | } 11 | expect(getMouseX(event)).toBe(190) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/utils/__tests__/getNumericPropertyValue.js: -------------------------------------------------------------------------------- 1 | import getNumericPropertyValue from '../getNumericPropertyValue' 2 | import computedStyle from '../computedStyle' 3 | 4 | jest.mock('../computedStyle') 5 | 6 | describe('getNumericPropertyValue', () => { 7 | it('returns the numeric portion within a property value of a DOM node', () => { 8 | computedStyle.mockImplementation(node => ({ 9 | getPropertyValue(prop) { 10 | return node.style[prop] 11 | }, 12 | })) 13 | const node = { 14 | style: { 15 | height: '100px', 16 | }, 17 | } 18 | const actual = getNumericPropertyValue(node, 'height') 19 | expect(actual).toBe(100) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/time.js: -------------------------------------------------------------------------------- 1 | import createTime from '../time' 2 | 3 | describe('createTime', () => { 4 | describe('timelineWidth', () => { 5 | it('calculates timelineWidth from start, end and scale', () => { 6 | const { timelineWidth } = createTime({ 7 | start: new Date('2017-01-01T00:00:00.000Z'), 8 | end: new Date('2018-01-01T00:00:00.000Z'), 9 | zoom: 10, // 10px === 1 day 10 | }) 11 | expect(timelineWidth).toBe(3650) 12 | }) 13 | 14 | it('scale relates to pixel width of one day', () => { 15 | const newYear = new Date('2017-01-01T00:00:00.000Z') 16 | const newYearMidday = new Date('2017-01-01T12:00:00.000Z') 17 | const { timelineWidth } = createTime({ 18 | start: newYear, 19 | end: newYearMidday, 20 | zoom: 100, 21 | }) 22 | expect(timelineWidth).toBe(50) 23 | }) 24 | 25 | it('uses viewportWidth if greater than daysZoomWidth', () => { 26 | const newYear = new Date('2017-01-01T00:00:00.000Z') 27 | const newYearMidday = new Date('2017-01-01T12:00:00.000Z') 28 | const { timelineWidth } = createTime({ 29 | start: newYear, 30 | end: newYearMidday, 31 | zoom: 1, 32 | viewportWidth: 1000, 33 | }) 34 | expect(timelineWidth).toBe(1000) 35 | }) 36 | 37 | it('minTimelineWidth ensures timelineWidth does not fall below minimum', () => { 38 | const newYear = new Date('2017-01-01T00:00:00.000Z') 39 | const newYearMidday = new Date('2017-01-01T12:00:00.000Z') 40 | const { timelineWidth } = createTime({ 41 | start: newYear, 42 | end: newYearMidday, 43 | zoom: 1, 44 | viewportWidth: 500, 45 | minWidth: 800, 46 | }) 47 | expect(timelineWidth).toBe(800) 48 | }) 49 | }) 50 | 51 | describe('toX()', () => { 52 | it('calculates correct x pixel position for given date (with pixel rounding)', () => { 53 | const start = new Date('2017-01-01T00:00:00.000Z') 54 | const end = new Date('2018-01-01T00:00:00.000Z') 55 | const { toX } = createTime({ start, end, zoom: 2 }) 56 | const nearMiddle = new Date('2017-07-01') 57 | const notClamped = new Date('2020-01-01') 58 | expect(toX(end)).toBe(730) 59 | expect(toX(start)).toBe(0) 60 | expect(toX(nearMiddle)).toBe(362) 61 | expect(toX(notClamped)).toBe(2190) 62 | }) 63 | }) 64 | 65 | describe('toStyleLeft()', () => { 66 | it('returns style object with correct "left" property', () => { 67 | const start = new Date('2017-01-01T00:00:00.000Z') 68 | const firstOfJune = new Date('2017-06-01T12:34:56.000Z') 69 | const end = new Date('2018-01-01T00:00:00.000Z') 70 | const { toStyleLeft } = createTime({ start, end, zoom: 2 }) 71 | expect(toStyleLeft(start)).toEqual({ left: '0px' }) 72 | expect(toStyleLeft(firstOfJune)).toEqual({ left: '303px' }) 73 | expect(toStyleLeft(end)).toEqual({ left: '730px' }) 74 | }) 75 | }) 76 | 77 | describe('toStyleLeftAndWidth()', () => { 78 | it('returns style object with correct "left" and "width" property', () => { 79 | const start = new Date('2017-01-01T00:00:00.000Z') 80 | const firstOfJune = new Date('2017-06-01T12:34:56.000Z') 81 | const end = new Date('2018-01-01T00:00:00.000Z') 82 | const { toStyleLeftAndWidth } = createTime({ start, end, zoom: 2 }) 83 | expect(toStyleLeftAndWidth(start, end)).toEqual({ left: '0px', width: '730px' }) 84 | expect(toStyleLeftAndWidth(firstOfJune, end)).toEqual({ left: '303px', width: '427px' }) 85 | }) 86 | }) 87 | 88 | describe('fromX', () => { 89 | it('calculates the date from a given x value', () => { 90 | const start = new Date('2017-01-01') 91 | const firstOfDecember = new Date('2017-12-01') 92 | const end = new Date('2018-01-01') 93 | const { fromX, toX } = createTime({ start, end, zoom: 2 }) 94 | expect(fromX(toX(start))).toEqual(start) 95 | expect(fromX(toX(firstOfDecember))).toEqual(firstOfDecember) 96 | expect(fromX(toX(end))).toEqual(end) 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/utils/classes.js: -------------------------------------------------------------------------------- 1 | const classes = (base, additional) => { 2 | if (!additional) { 3 | return base 4 | } 5 | if (typeof additional === 'string') { 6 | return `${base} ${additional}` 7 | } 8 | return `${base} ${additional.join(' ')}` 9 | } 10 | 11 | export default classes 12 | -------------------------------------------------------------------------------- /src/utils/formatDate.js: -------------------------------------------------------------------------------- 1 | const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] 2 | 3 | export const getMonth = date => monthNames[date.getMonth()] 4 | 5 | export const getDayMonth = date => `${date.getMonth() + 1}月${date.getDate()}日` 6 | -------------------------------------------------------------------------------- /src/utils/getGrid.js: -------------------------------------------------------------------------------- 1 | const getGrid = timebar => (timebar.find(row => row.useAsGrid) || {}).cells 2 | 3 | export default getGrid 4 | -------------------------------------------------------------------------------- /src/utils/getMouseX.js: -------------------------------------------------------------------------------- 1 | const getMouseX = e => { 2 | const target = e.currentTarget 3 | const bounds = target.getBoundingClientRect() 4 | return e.clientX - bounds.left 5 | } 6 | 7 | export default getMouseX 8 | -------------------------------------------------------------------------------- /src/utils/time.js: -------------------------------------------------------------------------------- 1 | const MILLIS_IN_A_DAY = 24 * 60 * 60 * 1000 2 | 3 | const create = ({ start, end, zoom, viewportWidth, minWidth }) => { 4 | const duration = end - start 5 | 6 | const days = duration / MILLIS_IN_A_DAY 7 | const daysZoomWidth = days * zoom 8 | 9 | let timelineWidth 10 | // if (daysZoomWidth > viewportWidth) { 11 | // timelineWidth = daysZoomWidth 12 | // } else { 13 | // timelineWidth = viewportWidth 14 | // } 15 | 16 | // if (timelineWidth < minWidth) { 17 | // timelineWidth = minWidth 18 | // } 19 | 20 | timelineWidth = Math.max(minWidth, viewportWidth * zoom) 21 | 22 | // console.log('daysZoomWidth ' + daysZoomWidth) 23 | // console.log('viewportWidth ' + viewportWidth) 24 | // console.log('timelineWidth ' + timelineWidth) 25 | 26 | const timelineWidthStyle = `${timelineWidth}px` 27 | 28 | const toX = from => { 29 | const value = (from - start) / duration 30 | return Math.round(value * timelineWidth) 31 | } 32 | 33 | const toStyleLeft = from => ({ 34 | left: `${toX(from)}px`, 35 | }) 36 | 37 | const toStyleLeftAndWidth = (from, to) => { 38 | const left = toX(from) 39 | return { 40 | left: `${left}px`, 41 | width: `${toX(to) - left}px`, 42 | } 43 | } 44 | 45 | const fromX = x => new Date(start.getTime() + (x / timelineWidth) * duration) 46 | 47 | return { 48 | timelineWidth, 49 | timelineWidthStyle, 50 | start, 51 | end, 52 | zoom, 53 | toX, 54 | toStyleLeft, 55 | toStyleLeftAndWidth, 56 | fromX, 57 | } 58 | } 59 | 60 | export default create 61 | --------------------------------------------------------------------------------