├── .babelrc ├── .editorconfig ├── .esdoc.json ├── .eslintrc ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── npm-publish.yml ├── .gitignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── date-types.md └── table-columns.md ├── package.json ├── src ├── components │ ├── body.js │ ├── marker.js │ ├── renderers.js │ ├── renderers.test.js │ ├── selector.js │ ├── selector.test.js │ └── timebar.js ├── consts │ └── timebarConsts.js ├── demo.html ├── demo.js ├── demo │ └── customRenderers.js ├── demo_index.js ├── index.js ├── setupTests.js ├── stories │ └── Timeline.stories.jsx ├── style.css ├── timeline.js ├── timeline.test.js └── utils │ ├── commonUtils.js │ ├── commonUtils.test.js │ ├── itemUtils.js │ ├── itemUtils.test.js │ ├── timeUtils.js │ └── timeUtils.test.js ├── webpack.common.js ├── webpack.demo.prod.js ├── webpack.dev.js ├── webpack.prod.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | # Matches multiple files with brace expansion notation 11 | # Set default charset 12 | [*.{js,jsx,py}] 13 | charset = utf-8 14 | 15 | # 4 space indentation 16 | [*.py] 17 | indent_style = space 18 | indent_size = 4 19 | 20 | [*.{js,jsx}] 21 | indent_size = 2 22 | 23 | # Tab indentation (no size specified) 24 | [Makefile] 25 | indent_style = tab 26 | 27 | # Use 2 spaces for the HTML files 28 | [*.html] 29 | indent_size = 2 30 | 31 | [*.md] 32 | trim_trailing_whitespace = false 33 | -------------------------------------------------------------------------------- /.esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./src", 3 | "destination": "./docs", 4 | "excludes": ["demo*", "demo\\/.*"], 5 | "plugins": [ 6 | {"name": "esdoc-standard-plugin"}, 7 | {"name": "esdoc-jsx-plugin", "option": {"enable": true}}, 8 | {"name": "esdoc-ecmascript-proposal-plugin", "option": {"all": true}} 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | // 0=off, 1=warning, 2=error 2 | { 3 | "extends": [ 4 | "prettier", 5 | "eslint:recommended", 6 | "plugin:import/errors", 7 | "plugin:import/warnings" 8 | ], 9 | "plugins": [ 10 | "babel", 11 | "react", 12 | "prettier" 13 | ], 14 | "parserOptions": { 15 | "ecmaVersion": 6, 16 | "sourceType": "module", 17 | "ecmaFeatures": { 18 | "jsx": true, 19 | "experimentalObjectRestSpread": true 20 | } 21 | }, 22 | "parser": "babel-eslint", 23 | "env": { 24 | "es6": true, 25 | "browser": true, 26 | "node": true, 27 | "jquery": true, 28 | "mocha": true 29 | }, 30 | "rules": { 31 | "prettier/prettier": 1, 32 | "quotes": [0, {"singleQuote": true}], 33 | "no-console": 1, 34 | "no-debugger": 1, 35 | "no-var": 1, 36 | "semi": [1, "always"], 37 | "no-trailing-spaces": 0, 38 | "eol-last": 0, 39 | "no-unused-vars": 0, 40 | "import/no-unresolved": 0, 41 | "no-underscore-dangle": 0, 42 | "no-alert": 0, 43 | "no-lone-blocks": 0, 44 | "jsx-quotes": 1, 45 | "react/display-name": [ 1, {"ignoreTranspilerName": false }], 46 | "react/forbid-prop-types": [1, {"forbid": ["any"]}], 47 | "react/jsx-boolean-value": 1, 48 | "react/jsx-closing-bracket-location": 0, 49 | "react/jsx-curly-spacing": 1, 50 | "react/jsx-indent-props": 0, 51 | "react/jsx-key": 1, 52 | "react/jsx-max-props-per-line": 0, 53 | "react/jsx-no-bind": 0, 54 | "react/jsx-no-duplicate-props": 1, 55 | "react/jsx-no-literals": 0, 56 | "react/jsx-no-undef": 1, 57 | "react/jsx-pascal-case": 1, 58 | "react/jsx-sort-prop-types": 0, 59 | "react/jsx-sort-props": 0, 60 | "react/jsx-uses-react": 1, 61 | "react/jsx-uses-vars": 1, 62 | "react/no-danger": 1, 63 | "react/no-did-mount-set-state": 1, 64 | "react/no-did-update-set-state": 1, 65 | "react/no-direct-mutation-state": 1, 66 | "react/no-multi-comp": 1, 67 | "react/no-set-state": 0, 68 | "react/no-unknown-property": 1, 69 | "react/prefer-es6-class": 1, 70 | "react/prop-types": 1, 71 | "react/react-in-jsx-scope": 1, 72 | "react/self-closing-comp": 1, 73 | "react/sort-comp": 1, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposed Change: 2 | _What is your change_ 3 | 4 | ## Change type 5 | 6 | _Put an `x` in the boxes that apply_ 7 | 8 | - [ ] Bugfix 9 | - [ ] New feature 10 | 11 | ## Checklist 12 | 13 | - [ ] I have added tests that prove my fix is effective or that my feature works 14 | - [ ] I have added necessary documentation (if appropriate) 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'yarn' 29 | - run: make test 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, release/npm ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '18 20 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16 15 | cache: 'yarn' 16 | - run: yarn install --frozen-lockfile 17 | - run: make test 18 | 19 | publish-npm: 20 | needs: build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: actions/setup-node@v3 25 | with: 26 | node-version: 16 27 | registry-url: https://registry.npmjs.org/ 28 | cache: 'yarn' 29 | - run: make 30 | - run: yarn publish 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build dir 2 | dist/ 3 | lib/ 4 | 5 | # Docs 6 | docs/ 7 | 8 | # Logs 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Dependency directories 14 | node_modules/ 15 | 16 | # dotenv environment variables file 17 | .env 18 | 19 | # IDEs 20 | .vscode/ 21 | .dir-locals.el 22 | .lvimrc 23 | .DS_Store 24 | /.idea 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | bracketSpacing: false, 3 | jsxBracketSameLine: true, 4 | tabWidth: 2, 5 | printWidth: 120, 6 | arrowParens: "avoid", 7 | singleQuote: true 8 | } 9 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials" 9 | ] 10 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased 4 | ## v1.1.2 5 | ### Added 6 | - Pass resolution props to timeline 7 | 8 | ## v1.1.1 9 | ### Fixed 10 | - Selection UI would bleed when working on very dense timeline rows 11 | with borders and/or margins 12 | 13 | ## v1.1.0 14 | ### Changed 15 | - Selection UI now matches functionality 16 | 17 | ## v1.0.14 18 | ### Added 19 | - Prop to customize shallow render logic 20 | 21 | ## v1.0.13 22 | ### Added 23 | - Option for shallow re-render check 24 | 25 | ## v1.0.12 26 | ### Fixed 27 | - Console error when loading page [#144] 28 | - Row layers only worked with min 1 item in row [#145] 29 | 30 | 31 | ## v1.0.11 32 | ### Added 33 | - Row layers 34 | - Change log 35 | ### Fixed 36 | - Selection box bug 37 | - Better class names [#138] 38 | 39 | ## v1.0.10 40 | ### Fixed 41 | - Fix a critical bug introduced in V1.0.9 42 | 43 | 44 | ## v1.0.9 45 | ### Changes 46 | - Make it so that only selected items can be dragged & resized 47 | 48 | 49 | ## v1.0.8 50 | ### Changes 51 | - Optimize demo build size 52 | - Throttle mouse movement 53 | ### Fixed 54 | - Restrict dragging to timeline 55 | 56 | 57 | ## v1.0.7 58 | ### Added 59 | - Add a group title renderer 60 | ### Fixed 61 | - Fix edge case bug for timebar 62 | 63 | 64 | ## v1.0.6 65 | ### Added 66 | - Add namespacing to timeline [#112] 67 | ### Changes 68 | - Single time lable for top bar [#118] 69 | ### Fixed 70 | - Remove dependancy on style.css [#116] 71 | - Current-time cursor error [#115] 72 | - Duplicate key error [#117] 73 | - Month calc error [#119] 74 | 75 | 76 | ## v1.0.5 77 | ### Added 78 | - Added auto-documentation 79 | ### Changed 80 | - Throttled mouse move - for performance 81 | ### Fixed 82 | - Fixed issue with timebar 83 | - Don't require style.css [#104] 84 | - Fix demo single select [#135] 85 | - Demo not working [Smaller build size] [#128] 86 | 87 | 88 | ## v1.0.4 89 | ### Changed 90 | - Swapped to inline source maps 91 | - Allow for custom timebar time format prop 92 | ### Removed 93 | - Removed excessive console logging 94 | ### Fixed 95 | - Fix no-render on ie11 96 | - Fix null indicator in top timebar 97 | 98 | 99 | ## v1.0.3 100 | ### Added 101 | - Got NPM working 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Leighton Lilford 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | mocha := node_modules/.bin/mocha 2 | 3 | .PHONY: all clean install demo docs 4 | 5 | all: clean install 6 | yarn build &&\ 7 | yarn build_lib 8 | 9 | demo: clean install docs 10 | yarn build_demo && \ 11 | mv docs/ dist/ 12 | 13 | docs: 14 | yarn docs 15 | 16 | run: install 17 | yarn start 18 | 19 | test: install 20 | env NODE_PATH=$$NODE_PATH:$$PWD/src/ $(mocha) --require @babel/register --require ignore-styles "./src/**/*.test.js" 21 | 22 | test-watch: install 23 | env NODE_PATH=$$NODE_PATH:$$PWD/src/ $(mocha) -w --require @babel/register --require ignore-styles "./src/**/*.test.js" 24 | 25 | install: 26 | yarn 27 | 28 | clean: 29 | rm -rf dist 30 | rm -rf lib 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Timeline 9000 2 | A performance focused timeline component in react 3 | ## Build Status 4 | [![Build Status](https://travis-ci.org/BHP-DevHub/react-timeline-9000.svg?branch=master)](https://travis-ci.org/BHP-DevHub/react-timeline-9000) 5 | [![CodeFactor](https://www.codefactor.io/repository/github/bhp-devhub/react-timeline-9000/badge)](https://www.codefactor.io/repository/github/bhp-devhub/react-timeline-9000) 6 | [![npm (scoped)](https://img.shields.io/npm/v/react-timeline-9000.svg)](https://www.npmjs.com/package/react-timeline-9000) 7 | 8 | ## Demo 9 | * http://react-timeline-9000.s3-website-ap-southeast-2.amazonaws.com/ 10 | 11 | ## Documentation 12 | * http://react-timeline-9000.s3-website-ap-southeast-2.amazonaws.com/docs/ 13 | 14 | 15 | ## Getting Started 16 | 17 | | Action | Command | 18 | | -------------- | ------------------------------------- | 19 | | Build | `$ make` | 20 | | Test | `$ make test` or `$ make test-watch` | 21 | | Run dev server | `$ make run` | 22 | 23 | * Add `import react-timeline-9000/lib/style.css` (or use your own styles based on this file) 24 | 25 | ## Contributing 26 | Feel free to make a PR :) 27 | 28 | # Interaction 29 | 30 | Default interaction for multiple selection is largely governed by the leading item, which is defined as the item that is directly interacted with when multiple items are selected. 31 | 32 | ## Dragging 33 | 34 | All items will move by the same horizontal delta and row changes will be calculated by the row delta of the leading item 35 | 36 | ## Resizing 37 | 38 | All items will gain the resize delta from the leading item. 39 | 40 | ### Overriding the default behaviour 41 | 42 | TBA 43 | 44 | `onInteraction(type, changes, leadTimeDelta, leaderGroupDelta,selectedItems)` 45 | 46 | # Props Summary 47 | 48 | See http://react-timeline-9000.s3-website-ap-southeast-2.amazonaws.com/docs/ for detailed docs 49 | 50 | ## Props 51 | | Name | Default | Description | 52 | | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------ | 53 | | groupOffset | | | 54 | | startDate | | | 55 | | endDate | | | 56 | | snapMinutes | | | 57 | | showCursorTime | | | 58 | | cursorTimeFormat | | | 59 | | itemHeight | | | 60 | | timelineMode | | | 61 | | timebarFormat | | | 62 | | itemRenderer | | | 63 | | groupRenderer | | | 64 | | shallowUpdateCheck | False | If true timeline will try to minimize re-renders . Set to false if items don't show up/update on prop change | 65 | | forceRedrawFunc | () => False | Function called when `shallowUpdateCheck`==true. If returns true the timeline will be redrawn. If false the library will decide if redrawing is required | 66 | | useMoment | True | If true timeline will use moment for dates (including for items and rowLayers); otherwise the type for dates is number | 67 | 68 | ## Data 69 | | Name | 70 | | ---------------- | 71 | | items | 72 | | groups | 73 | | selectedItems | 74 | 75 | ## Callbacks 76 | | Name | 77 | | ---------------- | 78 | | onItemClick | 79 | | onItemDoubleClick | 80 | | onItemContext | 81 | | onInteraction | 82 | | onRowClick | 83 | | onRowContext | 84 | | onRowDoubleClick | 85 | | onItemHover | 86 | | onItemLeave | 87 | 88 | # Styling 89 | * View `src/style.css` for styling examples. 90 | * For the default styles, import `react-timeline-9000/lib/style.css` 91 | 92 | ### Default Z-indexes 93 | | Item | Index | 94 | | ------------------------------------- | ----- | 95 | | Row Layers | 1 | 96 | | Vertical markers | 2 | 97 | | Timeline items | 3 | 98 | | Timeline items when dragging/resizing | 4 | 99 | | Selection box (for multi-select) | 5 | 100 | | Group column | 6 | 101 | 102 | -------------------------------------------------------------------------------- /docs/date-types.md: -------------------------------------------------------------------------------- 1 | # Timeline: Date types 2 | 3 | ## Props 4 | 5 | | Name | Type | Default | Description | 6 | | ------------|------------------|---------|---------------------------------------------- | 7 | | useMoment | Boolean | true | If true the dates from props should be moment objects. otherwise they will be dates in milliseconds (numbers) and will be converted internally in moment objects. | 8 | | startDate | Object or number | | The visible start date of the timeline as moment object or in milliseconds | 9 | | endDate | Object or number | | The visible end date of the timeline as moment object or in milliseconds | 10 | | items | Object[] | | Items that will be rendered in the grid | 11 | | rowLayers | Object[] | | List of layers that will be rendered for a row | 12 | 13 | Default date fields in an item: 14 | * start - moment object or date in milliseconds(number) - start of an item 15 | * end - moment object or date in milliseconds(number) - end of an item 16 | 17 | Default date fields in a row layer: 18 | * start - moment object or date in milliseconds(number) - start of a row layer 19 | * end - moment object or date in milliseconds(number) - end of a row layer 20 | 21 | ## Functions 22 | 23 | > **WARNING** All operations involving the dates mentioned above should pass through the following functions. It is forbidden to manipulate the dates from props directly. These function can be overriden in order to use other fields or for additional logic. 24 | 25 | ### getStartDate() 26 | 27 | It returns the start date of the timeline as moment. 28 | 29 | ### getEndDate() 30 | 31 | It returns the end date of the timeline as moment. 32 | 33 | ### getStartFromItem(item) 34 | 35 | It returns the start date of an item as moment. 36 | 37 | Params: 38 | | Name | Type | Description | 39 | |-----------|----------|-------------------------------------------------------------| 40 | | item | Object | Item that will be rendered in the grid. | 41 | 42 | ### getEndFromItem(item) 43 | 44 | It returns the end date of an item as moment. 45 | 46 | Params: 47 | | Name | Type | Description | 48 | |-----------|----------|-------------------------------------------------------------| 49 | | item | Object | Item that will be rendered in the grid. | 50 | 51 | ### setStartToItem(item, newDateAsMoment) 52 | 53 | Sets the start of the item to newDateAsMoment converted to moment or milliseconds depending on props.useMoment. 54 | 55 | Params: 56 | | Name | Type | Description | 57 | |-----------------|----------|-------------------------------------------------------------| 58 | | item | Object | Item that will be rendered in the grid. | 59 | | newDateAsMoment | Object | The new value of the start date as moment. | 60 | 61 | ### setEndToItem(item, newDateAsMoment) 62 | 63 | Sets the end of the item to newDateAsMoment converted to moment or milliseconds depending on props.useMoment. 64 | 65 | Params: 66 | | Name | Type | Description | 67 | |-----------------|----------|-------------------------------------------------------------| 68 | | item | Object | Item that will be rendered in the grid. | 69 | | newDateAsMoment | Object | The new value of the end date as moment. | 70 | 71 | ### getStartFromRowLayer(layer) 72 | 73 | It returns the start date of a row layer as moment. 74 | 75 | Params: 76 | | Name | Type | Description | 77 | |-----------|----------|-------------------------------------------------------------| 78 | | layer | Object | Row layer that will be rendered in the grid. | 79 | 80 | ### getEndFromRowLayer(layer) 81 | 82 | It returns the end date of a row layer as moment. 83 | 84 | Params: 85 | | Name | Type | Description | 86 | |-----------|----------|-------------------------------------------------------------| 87 | | layer | Object | Row layer that will be rendered in the grid. | 88 | 89 | ### setStartToRowLayer(layer, newDateAsMoment) 90 | 91 | Sets the start of the layer to newDateAsMoment converted to moment or milliseconds depending on props.useMoment. 92 | 93 | Params: 94 | | Name | Type | Description | 95 | |-----------------|----------|-------------------------------------------------------------| 96 | | layer | Object | Row layer that will be rendered in the grid. | 97 | | newDateAsMoment | Object | The new value of the end date as moment. | 98 | 99 | ### setEndToRowLayer(layer, newDateAsMoment) 100 | 101 | Sets the end of the layer to newDateAsMoment converted to moment or milliseconds depending on props.useMoment. 102 | 103 | Params: 104 | | Name | Type | Description | 105 | |-----------------|----------|-------------------------------------------------------------| 106 | | layer | Object | Row layer that will be rendered in the grid. | 107 | | newDateAsMoment | Object | The new value of the end date as moment. | 108 | 109 | ## Utils functions 110 | 111 | ### convertDateToMoment(date, useMoment) 112 | 113 | Returns the date as moment object. If useMoment is true, the date is already a moment object. This function is used by all getter functions above. 114 | 115 | Params: 116 | | Name | Type | Description | 117 | |------------|------------------|-----------------------------------------------------------------| 118 | | date | Object or number | The date to be converted to moment, it can be already a moment. | 119 | | useMoment | Boolean | Whether the date is already a moment or should be converted. | 120 | 121 | ### convertMomentToDateType(dateAsMoment, useMoment) 122 | 123 | Returns the date as moment object if useMoment is true, otherwise returns the date in milliseconds. 124 | 125 | Params: 126 | | Name | Type | Description | 127 | |--------------|---------|-------------------------------------------------------------------------| 128 | | dateAsMoment | Object | The new value of the end date as moment. | 129 | | useMoment | Boolean | Whether the date returned should be a moment object or in milliseconds. | 130 | -------------------------------------------------------------------------------- /docs/table-columns.md: -------------------------------------------------------------------------------- 1 | # Table columns 2 | 3 | Historically, the Timeline only had a single column to the left of the component. Then we improved this, by allowing the possibility to have multiple columns. Hence the left zone can now be regarded also as a table. 4 | 5 | ## Props in Timeline 6 | 7 | | Name | Type | Default | Description | 8 | |--------------------|----------|----------------------|---------------------------------------------------| 9 | | groups | object[] | | Rows data | 10 | | groupOffset | number | 150 | Single column mode: the width of the column. Multiple columns mode: the default width of a column (if column.width is not configured). | 11 | | groupRenderer | function | DefaultGroupRenderer | Single column mode: the renderer of a cell. Multiple columns mode: the default renderer of a cell, which may be overridden on a per column basis. | 12 | | groupTitleRenderer | function | | Single mode view: the renderer of a header cell. Multiple columns mode: the default renderer of a header cell, which may be overridden on a per column basis. | 13 | | tableColumns | object[] | [] | The columns that will be rendered using data from groups. | 14 | 15 | ## Props for a column (multiple columns mode) 16 | | Name | Type | Description | 17 | |----------------|---------------------|--------------------------------------------------------------------------| 18 | | width | number | The width of the column. | 19 | | labelProperty | string | The key used in the default cell renderer to show the label of the cell. | 20 | | cellRenderer | function or element | Custom cellRenderer. | 21 | | headerLabel | string | The label of the header used in the default header renderer. | 22 | | headerRenderer | function or element | Custom header renderer. | 23 | 24 | ## Header renderer 25 | 26 | Single column mode: 27 | * props.groupTitleRenderer is the renderer of the header cell. 28 | 29 | Multiple columns mode: 30 | * props.groupTitleRenderer is the default renderer of a header cell, which may be overridden on a per column basis. The default value of props.groupTitleRenderer is DefaultColumnHeaderRenderer that renders column.headerLabel. 31 | * column.headerRenderer is the custom renderer for a column. It can be a React element or a function/class component. 32 | 33 | ### DefaultColumnHeaderRenderer 34 | 35 | Default renderer for column header. 36 | 37 | #### Props 38 | 39 | * props.column - object - The properties of the column 40 | * props.column.headerLabel - string - The header's label 41 | 42 | #### Functions 43 | 44 | * getLabel() - returns the label of the header, by default props.column.headerLabel. 45 | 46 | ## Cell renderer 47 | 48 | Single column mode: 49 | * props.groupRenderer is the renderer of a cell. The default value of the renderer is DefaultGroupRenderer that renderers by default the title property from group. 50 | 51 | Multiple columns mode: 52 | * props.groupRenderer is the default renderer of a cell, which may be overridden on a per column basis. The default value of props.groupRenderer is DefaultGroupRenderer that renders column.labelProperty from group. 53 | * column.cellRenderer is the custom renderer for a cell. It can be a React element or a function/class component. 54 | 55 | * props.groupRenderer is the default renderer of a cell, which may be overridden on a per column basis. The default value of props.groupRenderer is DefaultGroupRenderer that renders column.labelProperty from group. 56 | 57 | ### DefaultGroupRenderer 58 | 59 | Default group renderer class (cell renderer). 60 | 61 | #### Props 62 | 63 | * props.group - object - The group (row) to be rendered. 64 | * props.group.id - string - The group's id. 65 | * props.labelProperty - The key of the data from group that should be rendered. 66 | 67 | #### Functions 68 | 69 | * getLabel() - returns the label of the cell, by default return props.labelProperty from props.group. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-timeline-9000", 3 | "version": "2.0.0", 4 | "description": "Performance focused timeline for react", 5 | "private": false, 6 | "scripts": { 7 | "build": "webpack --config webpack.prod.js", 8 | "build_lib": "babel src --out-dir lib --ignore demo*,setupTests.js,*.test.js --copy-files --source-maps inline", 9 | "build_demo": "webpack --config webpack.demo.prod.js", 10 | "start": "webpack-dev-server --open --config webpack.dev.js", 11 | "docs": "esdoc", 12 | "pretty": "prettier --write --tab-width 4 \"src/**/*.js\"", 13 | "precommit": "lint-staged", 14 | "storybook": "start-storybook -p 6006", 15 | "build-storybook": "build-storybook" 16 | }, 17 | "lint-staged": { 18 | "src/**/*.{js,jsx,json,css}": [ 19 | "prettier --single-quote --write", 20 | "git add" 21 | ] 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/React9k/react-timeline-9000.git" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "timeline" 30 | ], 31 | "engines": { 32 | "node": ">=4.2.4" 33 | }, 34 | "author": "Leighton Lilford", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/React9k/react-timeline-9000/issues" 38 | }, 39 | "files": [ 40 | "dist", 41 | "lib" 42 | ], 43 | "main": "lib/index.js", 44 | "homepage": "https://github.com/React9k/react-timeline-9000#readme", 45 | "devDependencies": { 46 | "@babel/cli": "^7.0.0", 47 | "@storybook/addon-actions": "^6.3.11", 48 | "@storybook/addon-essentials": "^6.3.11", 49 | "@storybook/addon-links": "^6.3.11", 50 | "@storybook/react": "^6.3.11", 51 | "antd": "^3.6.5", 52 | "babel-loader": "^8.2.5", 53 | "chai": "^4.1.2", 54 | "core-js": "^2.4.0", 55 | "css-loader": "^0.28.11", 56 | "enzyme": "^3.3.0", 57 | "enzyme-adapter-react-16": "^1.1.1", 58 | "esdoc": "^1.1.0", 59 | "esdoc-ecmascript-proposal-plugin": "^1.0.0", 60 | "esdoc-jsx-plugin": "^1.0.0", 61 | "esdoc-standard-plugin": "^1.0.0", 62 | "eslint": "^5.0.1", 63 | "eslint-config-prettier": "^2.9.0", 64 | "eslint-plugin-babel": "^5.1.0", 65 | "eslint-plugin-import": "^2.12.0", 66 | "eslint-plugin-prettier": "^2.6.0", 67 | "eslint-plugin-react": "^7.10.0", 68 | "html-webpack-plugin": "5.5.0", 69 | "husky": "^0.14.3", 70 | "ignore-styles": "^5.0.1", 71 | "jsdom": "^16.5.0", 72 | "lint-staged": "8.2.1", 73 | "mocha": "^5.2.0", 74 | "prettier": "^1.13.5", 75 | "react": "^17.0.2", 76 | "react-dom": "^17.0.2", 77 | "style-loader": "^0.21.0", 78 | "tern": "^0.21.0", 79 | "uglifyjs-webpack-plugin": "^2.2.0", 80 | "webpack": "^5.67.0", 81 | "webpack-cli": "^4.9.2", 82 | "webpack-dev-server": "^4.7.3", 83 | "webpack-merge": "^5.8.0" 84 | }, 85 | "dependencies": { 86 | "core-js": "^2.4.0", 87 | "interactjs": "^1.6.2", 88 | "lodash": "^4.17.19", 89 | "moment": "^2.22.2", 90 | "react-virtualized": "^9.19.1" 91 | }, 92 | "peerDependencies": { 93 | "react": "^16.4.1 || ^17.0.0", 94 | "react-dom": "^16.4.1 || ^17.0.0" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/body.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Timeline body/grid 3 | */ 4 | 5 | import React, {Component} from 'react'; 6 | import PropTypes from 'prop-types'; 7 | 8 | import {Grid} from 'react-virtualized'; 9 | 10 | class TimelineBody extends Component { 11 | componentDidMount() { 12 | this.forceUpdate(); 13 | } 14 | shouldComponentUpdate(nextProps) { 15 | const {props} = this; 16 | if (!props.shallowUpdateCheck) { 17 | return true; 18 | } 19 | 20 | if (props.columnCount !== nextProps.columnCount) { 21 | return true; 22 | } 23 | 24 | // prettier-ignore 25 | const shallowChange = props.height !== nextProps.height 26 | || props.width !== nextProps.width 27 | || props.rowCount !== nextProps.rowCount; 28 | 29 | if (props.forceRedrawFunc) { 30 | return shallowChange || props.forceRedrawFunc(props, nextProps); 31 | } 32 | 33 | return shallowChange; 34 | } 35 | render() { 36 | const {width, columnWidth, height, rowHeight, rowCount, columnCount} = this.props; 37 | const {grid_ref_callback, cellRenderer} = this.props; 38 | 39 | return ( 40 | 51 | ); 52 | } 53 | } 54 | 55 | TimelineBody.propTypes = { 56 | width: PropTypes.number.isRequired, 57 | columnWidth: PropTypes.func.isRequired, 58 | columnCount: PropTypes.number, 59 | height: PropTypes.number.isRequired, 60 | rowHeight: PropTypes.func.isRequired, 61 | rowCount: PropTypes.number.isRequired, 62 | grid_ref_callback: PropTypes.func.isRequired, 63 | cellRenderer: PropTypes.func.isRequired, 64 | shallowUpdateCheck: PropTypes.bool, 65 | forceRedrawFunc: PropTypes.func 66 | }; 67 | 68 | TimelineBody.defaultProps = { 69 | columnCount: 2, 70 | shallowUpdateCheck: false, 71 | forceRedrawFunc: null 72 | }; 73 | export default TimelineBody; 74 | -------------------------------------------------------------------------------- /src/components/marker.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Marker = props => { 5 | const {height, left, top} = props; 6 | return
; 7 | }; 8 | 9 | Marker.propTypes = { 10 | height: PropTypes.number.isRequired, 11 | left: PropTypes.number.isRequired, 12 | top: PropTypes.number.isRequired 13 | }; 14 | 15 | export default Marker; 16 | -------------------------------------------------------------------------------- /src/components/renderers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Default item renderer class 5 | * @param {object} props - Component props 6 | * @param {object} props.item - The item to be rendered 7 | * @param {string} props.item.title - The item's title 8 | * @param {?...object} props.rest - Any other arguments for the span tag 9 | */ 10 | export const DefaultItemRenderer = props => { 11 | const {item, ...rest} = props; 12 | 13 | return ( 14 | 15 | {item.title} 16 | 17 | ); 18 | }; 19 | 20 | /** 21 | * Default group (row) renderer class 22 | * @param {object} props - Component props 23 | * @param {string} props.labelProperty - The key of the data from group that should be rendered 24 | * @param {object} props.group - The group to be rendered 25 | * @param {string} props.group.id - The group's id 26 | */ 27 | export class DefaultGroupRenderer extends React.Component { 28 | /** 29 | * Returns the label of the cell. 30 | */ 31 | getLabel() { 32 | return this.props.group[this.props.labelProperty]; 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 | {this.getLabel()} 39 | 40 | ); 41 | } 42 | } 43 | 44 | /** 45 | * Default renderer for column header. 46 | * @param {object} props - Component props 47 | * @param {object} props.column - The properties of the column 48 | * @param {string} props.column.headerLabel - The header's label 49 | */ 50 | export class DefaultColumnHeaderRenderer extends React.Component { 51 | /** 52 | * Returns the label of the header. 53 | */ 54 | getLabel() { 55 | return this.props.column ? this.props.column.headerLabel : ''; 56 | } 57 | 58 | render() { 59 | return {this.getLabel()}; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/renderers.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import {shallow, mount} from 'enzyme'; 4 | import {expect} from 'chai'; 5 | 6 | import setup from 'setupTests'; 7 | import {DefaultItemRenderer, DefaultGroupRenderer} from './renderers'; 8 | 9 | describe('Item renderer', () => { 10 | it('should render the item', () => { 11 | const item = {title: 'my_test'}; 12 | const component = shallow(); 13 | expect(component.text()).to.contain('my_test'); 14 | }); 15 | }); 16 | describe('Group renderer', () => { 17 | it('should render the group w/o label property', () => { 18 | const group = {title: 'my_test'}; 19 | const component = shallow(); 20 | expect(component.text()).to.equals(''); 21 | }); 22 | it('should render the group w/ label property', () => { 23 | const group = {title: 'my_test'}; 24 | const component = shallow(); 25 | expect(component.text()).to.equals('my_test'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/selector.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | 4 | /** 5 | * Component to show a selection box (like on windows desktop) 6 | */ 7 | export default class SelectBox extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.curX = 0; 11 | this.curY = 0; 12 | this.startX = 0; 13 | this.startY = 0; 14 | } 15 | 16 | /** 17 | * Create the selection box 18 | * @param {number} x Starting x coordinate for selection box 19 | * @param {number} y Starting y coordinate for selection box 20 | */ 21 | start(x, y) { 22 | this.startX = x; 23 | this.startY = y; 24 | this.curX = 0; 25 | this.curY = 0; 26 | } 27 | 28 | /** 29 | * Update the selection box as the mouse moves 30 | * @param {number} x The current X coordinate of the mouse 31 | * @param {number} y The current Y coordinate of the mouse 32 | */ 33 | move(x, y) { 34 | this.curX = x; 35 | this.curY = y; 36 | this.forceUpdate(); 37 | } 38 | 39 | /** 40 | * Generally on mouse up. 41 | * Finish the selection box and return the rectangle created 42 | * @returns {Object} The selection rectangle 43 | * @property {number} top The top y coordinate 44 | * @property {number} left The left x coordinate 45 | * @property {number} width The width of the box 46 | * @property {number} height The height of the box 47 | */ 48 | end() { 49 | const {startX, startY, curX, curY} = this; 50 | const left = Math.min(startX, curX); 51 | const top = Math.min(startY, curY); 52 | const width = Math.abs(startX - curX); 53 | const height = Math.abs(startY - curY); 54 | let toReturn = {left, top, width, height}; 55 | 56 | this.startX = 0; 57 | this.startY = 0; 58 | this.curX = 0; 59 | this.curY = 0; 60 | this.forceUpdate(); 61 | return toReturn; 62 | } 63 | 64 | /** 65 | * @ignore 66 | */ 67 | render() { 68 | const {startX, startY, curX, curY} = this; 69 | const left = Math.min(startX, curX); 70 | const top = Math.min(startY, curY); 71 | const width = Math.abs(startX - curX); 72 | const height = Math.abs(startY - curY); 73 | let style = {left, top, width, height}; 74 | return
; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/selector.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import {shallow, mount} from 'enzyme'; 4 | import {expect} from 'chai'; 5 | 6 | import setup from 'setupTests'; 7 | import SelectBox from './selector'; 8 | 9 | describe('Selector', () => { 10 | it('should initialize to 0,0', () => { 11 | const component = shallow(); 12 | const instance = component.instance(); 13 | expect(instance.startX).to.equal(0); 14 | expect(instance.startY).to.equal(0); 15 | expect(instance.curX).to.equal(0); 16 | expect(instance.curY).to.equal(0); 17 | }); 18 | it('should set start coordinates correctly', () => { 19 | const component = shallow(); 20 | const instance = component.instance(); 21 | instance.start(33, 44); 22 | expect(instance.startX).to.equal(33); 23 | expect(instance.startY).to.equal(44); 24 | expect(instance.curX).to.equal(0); 25 | expect(instance.curY).to.equal(0); 26 | }); 27 | it('should set move coordinates correctly', () => { 28 | const component = shallow(); 29 | const instance = component.instance(); 30 | instance.start(33, 44); 31 | instance.move(55, 66); 32 | expect(instance.startX).to.equal(33); 33 | expect(instance.startY).to.equal(44); 34 | expect(instance.curX).to.equal(55); 35 | expect(instance.curY).to.equal(66); 36 | }); 37 | it('should return correct coordinates on end', () => { 38 | const component = shallow(); 39 | const instance = component.instance(); 40 | instance.start(33, 44); 41 | instance.move(55, 45); 42 | const endReturn = instance.end(); 43 | expect(endReturn).to.deep.equal({left: 33, top: 44, width: 22, height: 1}); 44 | }); 45 | it('should reset coordinates on end', () => { 46 | const component = shallow(); 47 | const instance = component.instance(); 48 | instance.start(33, 44); 49 | instance.move(55, 45); 50 | const endReturn = instance.end(); 51 | expect(instance.startX).to.equal(0); 52 | expect(instance.startY).to.equal(0); 53 | expect(instance.curX).to.equal(0); 54 | expect(instance.curY).to.equal(0); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/components/timebar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import _ from 'lodash'; 6 | import moment from 'moment'; 7 | import {intToPix} from '../utils/commonUtils'; 8 | import {DefaultColumnHeaderRenderer} from './renderers'; 9 | import {timebarFormat as defaultTimebarFormat} from '../consts/timebarConsts'; 10 | 11 | /** 12 | * Timebar component - displays the current time on top of the timeline 13 | */ 14 | export default class Timebar extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = {}; 18 | 19 | this.guessResolution = this.guessResolution.bind(this); 20 | this.renderBar = this.renderBar.bind(this); 21 | this.renderTopBar = this.renderTopBar.bind(this); 22 | this.renderBottomBar = this.renderBottomBar.bind(this); 23 | } 24 | 25 | componentWillMount() { 26 | this.guessResolution(); 27 | } 28 | /** 29 | * On new props we check if a resolution is given, and if not we guess one 30 | * @param {Object} nextProps Props coming in 31 | */ 32 | componentWillReceiveProps(nextProps) { 33 | if (nextProps.top_resolution && nextProps.bottom_resolution) { 34 | this.setState({resolution: {top: nextProps.top_resolution, bottom: nextProps.bottom_resolution}}); 35 | } else { 36 | this.guessResolution(nextProps.start, nextProps.end); 37 | } 38 | } 39 | 40 | /** 41 | * Attempts to guess the resolution of the top and bottom halves of the timebar based on the viewable date range. 42 | * Sets resolution to state. 43 | * @param {moment} start Start date for the timebar 44 | * @param {moment} end End date for the timebar 45 | */ 46 | guessResolution(start, end) { 47 | if (!start || !end) { 48 | start = this.props.start; 49 | end = this.props.end; 50 | } 51 | const durationMilliSecs = end.diff(start); 52 | /// 1ms -> 1s 53 | if (durationMilliSecs <= 1000) this.setState({resolution: {top: 'second', bottom: 'millisecond'}}); 54 | // 1s -> 2m 55 | else if (durationMilliSecs <= 60 * 2 * 1000) this.setState({resolution: {top: 'minute', bottom: 'second'}}); 56 | // 2m -> 2h 57 | else if (durationMilliSecs <= 60 * 60 * 2 * 1000) this.setState({resolution: {top: 'hour', bottom: 'minute'}}); 58 | // 2h -> 3d 59 | else if (durationMilliSecs <= 24 * 60 * 60 * 3 * 1000) this.setState({resolution: {top: 'day', bottom: 'hour'}}); 60 | // 1d -> 30d 61 | else if (durationMilliSecs <= 30 * 24 * 60 * 60 * 1000) this.setState({resolution: {top: 'month', bottom: 'day'}}); 62 | //30d -> 1y 63 | else if (durationMilliSecs <= 365 * 24 * 60 * 60 * 1000) 64 | this.setState({resolution: {top: 'year', bottom: 'month'}}); 65 | // 1y -> 66 | else this.setState({resolution: {top: 'year', bottom: 'year'}}); 67 | } 68 | 69 | /** 70 | * Renderer for top bar. 71 | * @returns {Object} JSX for top menu bar - based of time format & resolution 72 | */ 73 | renderTopBar() { 74 | let res = this.state.resolution.top; 75 | return this.renderBar({format: this.props.timeFormats.majorLabels[res], type: res}); 76 | } 77 | /** 78 | * Renderer for bottom bar. 79 | * @returns {Object} JSX for bottom menu bar - based of time format & resolution 80 | */ 81 | renderBottomBar() { 82 | let res = this.state.resolution.bottom; 83 | return this.renderBar({format: this.props.timeFormats.minorLabels[res], type: res}); 84 | } 85 | /** 86 | * Gets the number of pixels per segment of the timebar section (using the resolution) 87 | * @param {moment} date The date being rendered. This is used to figure out how many days are in the month 88 | * @param {string} resolutionType Timebar section resolution [Year; Month...] 89 | * @returns {number} The number of pixels per segment 90 | */ 91 | getPixelIncrement(date, resolutionType, offset = 0) { 92 | const {start, end} = this.props; 93 | const width = this.props.width - this.props.leftOffset; 94 | 95 | const start_end_ms = end.diff(start, 'milliseconds'); 96 | const pixels_per_ms = width / start_end_ms; 97 | function isLeapYear(year) { 98 | return year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0); 99 | } 100 | const daysInYear = isLeapYear(date.year()) ? 366 : 365; 101 | let inc = width; 102 | switch (resolutionType) { 103 | case 'year': 104 | inc = pixels_per_ms * 1000 * 60 * 60 * 24 * (daysInYear - offset); 105 | break; 106 | case 'month': 107 | inc = pixels_per_ms * 1000 * 60 * 60 * 24 * (date.daysInMonth() - offset); 108 | break; 109 | case 'day': 110 | inc = pixels_per_ms * 1000 * 60 * 60 * (24 - offset); 111 | break; 112 | case 'hour': 113 | inc = pixels_per_ms * 1000 * 60 * (60 - offset); 114 | break; 115 | case 'minute': 116 | inc = pixels_per_ms * 1000 * 60 - offset; 117 | break; 118 | case 'second': 119 | inc = pixels_per_ms * 1000 - offset; 120 | break; 121 | case 'millisecond': 122 | inc = pixels_per_ms - offset; 123 | break; 124 | default: 125 | break; 126 | } 127 | return Math.min(inc, width); 128 | } 129 | /** 130 | * Renders an entire segment of the timebar (top or bottom) 131 | * @param {string} resolution The resolution to render at [Year; Month...] 132 | * @returns {Object[]} A list of sections (making up a segment) to be rendered 133 | * @property {string} label The text displayed in the section (usually the date/time) 134 | * @property {boolean} isSelected Whether the section is being 'touched' when dragging/resizing 135 | * @property {number} size The number of pixels the segment will take up 136 | * @property {number|string} key Key for react 137 | */ 138 | renderBar(resolution) { 139 | const {start, end, selectedRanges} = this.props; 140 | const width = this.props.width - this.props.leftOffset; 141 | 142 | let currentDate = start.clone(); 143 | let timeIncrements = []; 144 | let pixelsLeft = width; 145 | let labelSizeLimit = 60; 146 | 147 | function _addTimeIncrement(initialOffset, offsetType, stepFunc) { 148 | let offset = null; 149 | while (currentDate.isBefore(end) && pixelsLeft > 0) { 150 | // if this is the first 'block' it may be cut off at the start 151 | if (pixelsLeft === width) { 152 | offset = initialOffset; 153 | } else { 154 | offset = moment.duration(0); 155 | } 156 | let pixelIncrements = Math.min( 157 | this.getPixelIncrement(currentDate, resolution.type, offset.as(offsetType)), 158 | pixelsLeft 159 | ); 160 | const labelSize = pixelIncrements < labelSizeLimit ? 'short' : 'long'; 161 | let label = currentDate.format(resolution.format[labelSize]); 162 | let isSelected = _.some(selectedRanges, s => { 163 | return ( 164 | currentDate.isSameOrAfter(s.start.clone().startOf(resolution.type)) && 165 | currentDate.isSameOrBefore(s.end.clone().startOf(resolution.type)) 166 | ); 167 | }); 168 | timeIncrements.push({label, isSelected, size: pixelIncrements, key: pixelsLeft}); 169 | stepFunc(currentDate, offset); 170 | pixelsLeft -= pixelIncrements; 171 | } 172 | } 173 | 174 | const addTimeIncrement = _addTimeIncrement.bind(this); 175 | 176 | if (resolution.type === 'year') { 177 | const offset = moment.duration(currentDate.diff(currentDate.clone().startOf('year'))); 178 | addTimeIncrement(offset, 'months', (currentDt, offst) => currentDt.subtract(offst).add(1, 'year')); 179 | } else if (resolution.type === 'month') { 180 | const offset = moment.duration(currentDate.diff(currentDate.clone().startOf('month'))); 181 | addTimeIncrement(offset, 'days', (currentDt, offst) => currentDt.subtract(offst).add(1, 'month')); 182 | } else if (resolution.type === 'day') { 183 | const offset = moment.duration(currentDate.diff(currentDate.clone().startOf('day'))); 184 | addTimeIncrement(offset, 'hours', (currentDt, offst) => currentDt.subtract(offst).add(1, 'days')); 185 | } else if (resolution.type === 'hour') { 186 | const offset = moment.duration(currentDate.diff(currentDate.clone().startOf('hour'))); 187 | addTimeIncrement(offset, 'minutes', (currentDt, offst) => currentDt.subtract(offst).add(1, 'hours')); 188 | } else if (resolution.type === 'minute') { 189 | const offset = moment.duration(currentDate.diff(currentDate.clone().startOf('minute'))); 190 | addTimeIncrement(offset, 'minutes', (currentDt, offst) => currentDt.subtract(offst).add(1, 'minutes')); 191 | } else if (resolution.type === 'second') { 192 | const offset = moment.duration(currentDate.diff(currentDate.clone().startOf('second'))); 193 | addTimeIncrement(offset, 'second', (currentDt, offst) => currentDt.subtract(offst).add(1, 'seconds')); 194 | } else if (resolution.type === 'millisecond') { 195 | addTimeIncrement(moment.duration(0), 'millisecond', (currentDt, offst) => currentDt.add(1, 'milliseconds')); 196 | } 197 | return timeIncrements; 198 | } 199 | 200 | /** 201 | * It renders the header of a column in multi columns mode. Default renderer: props.groupTitleRenderer; 202 | * which may be overriden per column: column.headerRender (react element or function). 203 | * @param {object} column 204 | */ 205 | renderColumnHeader(column, index) { 206 | const columnWidth = column.width ? column.width : this.props.groupOffset; 207 | return ( 208 |
209 | {column.headerRenderer ? ( 210 | React.isValidElement(column.headerRenderer) ? ( 211 | column.headerRenderer 212 | ) : ( 213 | 214 | ) 215 | ) : ( 216 | 217 | )} 218 |
219 | ); 220 | } 221 | 222 | /** 223 | * Renders the timebar 224 | * @returns {Object} Timebar component 225 | */ 226 | render() { 227 | const {cursorTime, tableColumns} = this.props; 228 | const topBarComponent = this.renderTopBar(); 229 | const bottomBarComponent = this.renderBottomBar(); 230 | const GroupTitleRenderer = this.props.groupTitleRenderer; 231 | 232 | // Only show the cursor on 1 of the top bar segments 233 | // Pick the segment that has the biggest size 234 | let topBarCursorKey = null; 235 | if (topBarComponent.length > 1 && topBarComponent[1].size > topBarComponent[0].size) 236 | topBarCursorKey = topBarComponent[1].key; 237 | else if (topBarComponent.length > 0) topBarCursorKey = topBarComponent[0].key; 238 | 239 | return ( 240 |
241 | {/* Single column mode */} 242 | {(!tableColumns || tableColumns.length == 0) && ( 243 |
244 | 245 |
246 | )} 247 | {/* Multiple columns mode */} 248 | {tableColumns && 249 | tableColumns.length > 0 && 250 | tableColumns.map((column, index) => { 251 | return this.renderColumnHeader(column, index); 252 | })} 253 |
254 |
255 | {_.map(topBarComponent, i => { 256 | let topLabel = i.label; 257 | if (cursorTime && i.key === topBarCursorKey) { 258 | topLabel += ` [${cursorTime}]`; 259 | } 260 | let className = 'rct9k-timebar-item'; 261 | if (i.isSelected) className += ' rct9k-timebar-item-selected'; 262 | return ( 263 | 264 | {topLabel} 265 | 266 | ); 267 | })} 268 |
269 |
270 | {_.map(bottomBarComponent, i => { 271 | let className = 'rct9k-timebar-item'; 272 | if (i.isSelected) className += ' rct9k-timebar-item-selected'; 273 | return ( 274 | 275 | {i.label} 276 | 277 | ); 278 | })} 279 |
280 |
281 |
282 | ); 283 | } 284 | } 285 | 286 | Timebar.propTypes = { 287 | cursorTime: PropTypes.any, 288 | groupTitleRenderer: PropTypes.func, 289 | start: PropTypes.object.isRequired, //moment 290 | end: PropTypes.object.isRequired, //moment 291 | width: PropTypes.number.isRequired, 292 | leftOffset: PropTypes.number, 293 | top_resolution: PropTypes.string, 294 | bottom_resolution: PropTypes.string, 295 | selectedRanges: PropTypes.arrayOf(PropTypes.object), // [start: moment ,end: moment (end)] 296 | timeFormats: PropTypes.object, 297 | tableColumns: PropTypes.arrayOf(PropTypes.object) 298 | }; 299 | Timebar.defaultProps = { 300 | selectedRanges: [], 301 | groupTitleRenderer: DefaultColumnHeaderRenderer, 302 | leftOffset: 0, 303 | timeFormats: defaultTimebarFormat, 304 | tableColumns: [] 305 | }; 306 | -------------------------------------------------------------------------------- /src/consts/timebarConsts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Default timebar format 3 | */ 4 | export const timebarFormat = { 5 | majorLabels: { 6 | millisecond: { 7 | short: 'SSS', //001 8 | long: 'mm:ss.SSS' //01:10.001 9 | }, 10 | second: { 11 | short: 'ss', //10 12 | long: 'HH:mm:ss' //01:10 13 | }, 14 | minute: { 15 | short: 'mm', //01 16 | long: 'HH:mm' //12:01 17 | }, 18 | hour: { 19 | short: 'HH', //13 20 | long: 'HH:mm' //13:00 21 | }, 22 | day: { 23 | short: 'Do', //1st 24 | long: 'ddd, LL' //Sun, July 3, 2018 25 | }, 26 | month: { 27 | short: 'MMM', //Jan 28 | long: 'MMMM YYYY' //January 2018 29 | }, 30 | year: { 31 | short: 'YYYY', //2018 32 | long: 'YYYY' //2018 33 | } 34 | }, 35 | minorLabels: { 36 | millisecond: { 37 | short: 'SSS', //001 38 | long: 'mm:ss.SSS' //01:10.001 39 | }, 40 | second: { 41 | short: 'ss', //10 42 | long: 'HH:mm:ss' //01:10 43 | }, 44 | minute: { 45 | short: 'mm', //01 46 | long: 'HH:mm' //12:01 47 | }, 48 | hour: { 49 | short: 'HH', //13 50 | long: 'HH:mm' //13:00 51 | }, 52 | day: { 53 | short: 'D', //1 54 | long: 'ddd Do' //Sun 1st 55 | }, 56 | month: { 57 | short: 'MM', //02 58 | long: 'MMMM' //January 59 | }, 60 | year: { 61 | short: 'YYYY', //2018 62 | long: 'YYYY' //2018 63 | } 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Timeline 9000 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/demo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, {Component} from 'react'; 4 | import moment from 'moment'; 5 | import _ from 'lodash'; 6 | 7 | import Timeline from './timeline'; 8 | import { 9 | customItemRenderer, 10 | customGroupRenderer, 11 | CustomCellRenderer, 12 | CustomColumnHeaderRenderer 13 | } from './demo/customRenderers'; 14 | 15 | import {Layout, Form, InputNumber, Button, DatePicker, Checkbox, Switch, Icon} from 'antd'; 16 | import 'antd/dist/antd.css'; 17 | import './style.css'; 18 | 19 | const {TIMELINE_MODES} = Timeline; 20 | 21 | const ITEM_DURATIONS = [moment.duration(6, 'hours'), moment.duration(12, 'hours'), moment.duration(18, 'hours')]; 22 | 23 | const COLORS = ['#0099cc', '#f03a36', '#06ad96', '#fce05b', '#dd5900', '#cc6699']; 24 | 25 | // Moment timezones can be enabled using the following 26 | // import moment from 'moment-timezone'; 27 | // moment.locale('en-au'); 28 | // moment.tz.setDefault('Australia/Perth'); 29 | 30 | export default class DemoTimeline extends Component { 31 | constructor(props) { 32 | super(props); 33 | 34 | const startDate = moment('2018-08-31'); 35 | //const endDate = startDate.clone().add(4, 'days'); 36 | const endDate = moment('2018-09-30'); 37 | this.state = { 38 | selectedItems: [], 39 | rows: 100, 40 | items_per_row: 30, 41 | snap: 60, 42 | startDate, 43 | endDate, 44 | message: '', 45 | timelineMode: TIMELINE_MODES.SELECT | TIMELINE_MODES.DRAG | TIMELINE_MODES.RESIZE, 46 | multipleColumnsMode: false, 47 | useMoment: true 48 | }; 49 | this.reRender = this.reRender.bind(this); 50 | this.zoomIn = this.zoomIn.bind(this); 51 | this.zoomOut = this.zoomOut.bind(this); 52 | this.toggleCustomRenderers = this.toggleCustomRenderers.bind(this); 53 | this.toggleSelectable = this.toggleSelectable.bind(this); 54 | this.toggleDraggable = this.toggleDraggable.bind(this); 55 | this.toggleResizable = this.toggleResizable.bind(this); 56 | this.toggleUseMoment = this.toggleUseMoment.bind(this); 57 | this.toggleMultipleColumnsMode = this.toggleMultipleColumnsMode.bind(this); 58 | } 59 | 60 | componentWillMount() { 61 | this.reRender(); 62 | } 63 | 64 | reRender(useMoment = this.state.useMoment) { 65 | const list = []; 66 | const groups = []; 67 | const {snap} = this.state; 68 | 69 | this.key = 0; 70 | for (let i = 0; i < this.state.rows; i++) { 71 | groups.push({id: i, title: `Row ${i}`, description: `Description for row ${i}`}); 72 | for (let j = 0; j < this.state.items_per_row; j++) { 73 | this.key += 1; 74 | const color = COLORS[(i + j) % COLORS.length]; 75 | const duration = ITEM_DURATIONS[Math.floor(Math.random() * ITEM_DURATIONS.length)]; 76 | // let start = last_moment; 77 | let start = moment( 78 | Math.floor( 79 | Math.random() * (this.state.endDate.valueOf() - this.state.startDate.valueOf()) + 80 | this.state.startDate.valueOf() 81 | ) 82 | ); 83 | let end = start.clone().add(duration); 84 | 85 | // Round to the nearest snap distance 86 | const roundedStartSeconds = Math.floor(start.second() / snap) * snap; 87 | const roundedEndSeconds = Math.floor(end.second() / snap) * snap; 88 | start.second(roundedStartSeconds); 89 | end.second(roundedEndSeconds); 90 | 91 | list.push({ 92 | key: this.key, 93 | title: duration.humanize(), 94 | color, 95 | row: i, 96 | start: useMoment ? start : start.valueOf(), 97 | end: useMoment ? end : end.valueOf() 98 | }); 99 | } 100 | } 101 | 102 | const tableColumns = [ 103 | // default renderers 104 | { 105 | width: 100, 106 | headerLabel: 'Title', 107 | labelProperty: 'title' 108 | }, 109 | // custom renderers: react elements 110 | { 111 | width: 250, 112 | cellRenderer: Checkbox, 113 | headerRenderer: ( 114 | 115 | Custom check 116 | 117 | ) 118 | }, 119 | // custom renderers: class component 120 | { 121 | width: 100, 122 | headerRenderer: CustomColumnHeaderRenderer, 123 | cellRenderer: CustomCellRenderer 124 | } 125 | ]; 126 | 127 | // this.state = {selectedItems: [11, 12], groups, items: list}; 128 | this.forceUpdate(); 129 | this.setState({items: list, groups, tableColumns, useMoment}); 130 | } 131 | 132 | handleRowClick = (e, rowNumber, clickedTime, snappedClickedTime) => { 133 | const message = `Row Click row=${rowNumber} @ time/snapped=${clickedTime.toString()}/${snappedClickedTime.toString()}`; 134 | this.setState({selectedItems: [], message}); 135 | }; 136 | zoomIn() { 137 | let currentMilliseconds = this.state.endDate.diff(this.state.startDate, 'milliseconds'); 138 | let newSec = currentMilliseconds / 2; 139 | this.setState({endDate: this.state.startDate.clone().add(newSec, 'milliseconds')}); 140 | } 141 | zoomOut() { 142 | let currentMilliseconds = this.state.endDate.diff(this.state.startDate, 'milliseconds'); 143 | let newSec = currentMilliseconds * 2; 144 | this.setState({endDate: this.state.startDate.clone().add(newSec, 'milliseconds')}); 145 | } 146 | 147 | toggleCustomRenderers(checked) { 148 | this.setState({useCustomRenderers: checked}); 149 | } 150 | 151 | toggleSelectable() { 152 | const {timelineMode} = this.state; 153 | let newMode = timelineMode ^ TIMELINE_MODES.SELECT; 154 | this.setState({timelineMode: newMode, message: 'Timeline mode change: ' + timelineMode + ' -> ' + newMode}); 155 | } 156 | toggleDraggable() { 157 | const {timelineMode} = this.state; 158 | let newMode = timelineMode ^ TIMELINE_MODES.DRAG; 159 | this.setState({timelineMode: newMode, message: 'Timeline mode change: ' + timelineMode + ' -> ' + newMode}); 160 | } 161 | toggleResizable() { 162 | const {timelineMode} = this.state; 163 | let newMode = timelineMode ^ TIMELINE_MODES.RESIZE; 164 | this.setState({timelineMode: newMode, message: 'Timeline mode change: ' + timelineMode + ' -> ' + newMode}); 165 | } 166 | toggleUseMoment() { 167 | const {useMoment} = this.state; 168 | this.reRender(!useMoment); 169 | } 170 | toggleMultipleColumnsMode() { 171 | const {multipleColumnsMode} = this.state; 172 | this.setState({multipleColumnsMode: !multipleColumnsMode}); 173 | } 174 | handleItemClick = (e, key) => { 175 | const message = `Item Click ${key}`; 176 | const {selectedItems} = this.state; 177 | 178 | let newSelection = selectedItems.slice(); 179 | 180 | // If the item is already selected, then unselected 181 | const idx = selectedItems.indexOf(key); 182 | if (idx > -1) { 183 | // Splice modifies in place and returns removed elements 184 | newSelection.splice(idx, 1); 185 | } else { 186 | newSelection.push(Number(key)); 187 | } 188 | 189 | this.setState({selectedItems: newSelection, message}); 190 | }; 191 | 192 | handleItemDoubleClick = (e, key) => { 193 | const message = `Item Double Click ${key}`; 194 | this.setState({message}); 195 | }; 196 | 197 | handleItemContextClick = (e, key) => { 198 | const message = `Item Context ${key}`; 199 | this.setState({message}); 200 | }; 201 | 202 | handleRowDoubleClick = (e, rowNumber, clickedTime, snappedClickedTime) => { 203 | const message = `Row Double Click row=${rowNumber} time/snapped=${clickedTime.toString()}/${snappedClickedTime.toString()}`; 204 | 205 | const randomIndex = Math.floor(Math.random() * Math.floor(ITEM_DURATIONS.length)); 206 | 207 | let start = snappedClickedTime.clone(); 208 | let end = snappedClickedTime.clone().add(ITEM_DURATIONS[randomIndex]); 209 | this.key++; 210 | 211 | const item = { 212 | key: this.key++, 213 | title: 'New item', 214 | color: 'yellow', 215 | row: rowNumber, 216 | start: start, 217 | end: end 218 | }; 219 | 220 | const newItems = _.clone(this.state.items); 221 | newItems.push(item); 222 | 223 | this.setState({items: newItems, message}); 224 | }; 225 | 226 | handleRowContextClick = (e, rowNumber, clickedTime, snappedClickedTime) => { 227 | const message = `Row Click row=${rowNumber} @ time/snapped=${clickedTime.toString()}/${snappedClickedTime.toString()}`; 228 | this.setState({message}); 229 | }; 230 | 231 | handleInteraction = (type, changes, items) => { 232 | /** 233 | * this is to appease the codefactor gods, 234 | * whose wrath condemns those who dare 235 | * repeat code beyond the sacred 5 lines... 236 | */ 237 | function absorbChange(itemList, selectedItems) { 238 | itemList.forEach(item => { 239 | let i = selectedItems.find(i => { 240 | return i.key == item.key; 241 | }); 242 | if (i) { 243 | item = i; 244 | item.title = moment.duration(moment(item.end).diff(moment(item.start))).humanize(); 245 | } 246 | }); 247 | } 248 | 249 | switch (type) { 250 | case Timeline.changeTypes.dragStart: { 251 | return this.state.selectedItems; 252 | } 253 | case Timeline.changeTypes.dragEnd: { 254 | const newItems = _.clone(this.state.items); 255 | 256 | absorbChange(newItems, items); 257 | this.setState({items: newItems}); 258 | break; 259 | } 260 | case Timeline.changeTypes.resizeStart: { 261 | return this.state.selectedItems; 262 | } 263 | case Timeline.changeTypes.resizeEnd: { 264 | const newItems = _.clone(this.state.items); 265 | 266 | // Fold the changes into the item list 267 | absorbChange(newItems, items); 268 | 269 | this.setState({items: newItems}); 270 | break; 271 | } 272 | case Timeline.changeTypes.itemsSelected: { 273 | this.setState({selectedItems: _.map(changes, 'key')}); 274 | break; 275 | } 276 | default: 277 | return changes; 278 | } 279 | }; 280 | 281 | render() { 282 | const { 283 | selectedItems, 284 | rows, 285 | items_per_row, 286 | snap, 287 | startDate, 288 | endDate, 289 | items, 290 | groups, 291 | message, 292 | useCustomRenderers, 293 | timelineMode, 294 | useMoment, 295 | multipleColumnsMode, 296 | tableColumns 297 | } = this.state; 298 | const rangeValue = [startDate, endDate]; 299 | 300 | const selectable = (TIMELINE_MODES.SELECT & timelineMode) === TIMELINE_MODES.SELECT; 301 | const draggable = (TIMELINE_MODES.DRAG & timelineMode) === TIMELINE_MODES.DRAG; 302 | const resizeable = (TIMELINE_MODES.RESIZE & timelineMode) === TIMELINE_MODES.RESIZE; 303 | 304 | const rowLayers = []; 305 | for (let i = 0; i < rows; i += 1) { 306 | if (i % 5 === 0 && i !== 0) { 307 | continue; 308 | } 309 | let curDate = startDate.clone(); 310 | while (curDate.isSameOrBefore(endDate)) { 311 | const dayOfWeek = Number(curDate.format('d')); // 0 -> 6: Sun -> Sat 312 | let bandDuration = 0; // days 313 | let color = ''; 314 | if (dayOfWeek % 6 === 0) { 315 | color = 'blue'; 316 | bandDuration = dayOfWeek === 6 ? 2 : 1; // 2 if sat, 1 if sun 317 | } else { 318 | color = 'green'; 319 | bandDuration = 6 - dayOfWeek; 320 | } 321 | 322 | rowLayers.push({ 323 | start: this.state.useMoment ? curDate.clone() : curDate.valueOf(), 324 | end: this.state.useMoment 325 | ? curDate.clone().add(bandDuration, 'days') 326 | : curDate 327 | .clone() 328 | .add(bandDuration, 'days') 329 | .valueOf(), 330 | style: {backgroundColor: color, opacity: '0.3'}, 331 | rowNumber: i 332 | }); 333 | curDate.add(bandDuration, 'days'); 334 | } 335 | } 336 | 337 | return ( 338 |
339 |
340 |
341 | 342 | this.setState({rows: e})} /> 343 | 344 | 345 | this.setState({items_per_row: e})} /> 346 | 347 | 348 | this.setState({snap: e})} /> 349 | 350 | 351 | { 356 | this.setState({startDate: e[0], endDate: e[1]}, () => this.reRender()); 357 | }} 358 | /> 359 | 360 | 361 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | Enable selecting 377 | 378 | 379 | 380 | 381 | Enable dragging 382 | 383 | 384 | 385 | 386 | Enable resizing 387 | 388 | 389 | 390 | 391 | Use moment for dates 392 | 393 | 394 | 395 | 396 | Multiple columns mode 397 | 398 | 399 |
400 |
401 | Debug: 402 | {message} 403 |
404 |
405 |
Group title
: undefined} 427 | /> 428 |
429 | ); 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/demo/customRenderers.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | export function customItemRenderer(props) { 4 | const {item, ...rest} = props; 5 | const text = `${item.start.format('HH:mm')} - ${item.end.format('HH:mm')}`; 6 | return {text} ; 7 | } 8 | 9 | export function customGroupRenderer(props) { 10 | const {group, ...rest} = props; 11 | 12 | return ( 13 | 14 | Custom {group.title} 15 | 16 | ); 17 | } 18 | 19 | export class CustomCellRenderer extends React.Component { 20 | render() { 21 | return {this.props.group.description}; 22 | } 23 | } 24 | 25 | export class CustomColumnHeaderRenderer extends React.Component { 26 | render() { 27 | return Description; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/demo_index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import DemoTimeline from 'demo'; 7 | 8 | ReactDOM.render(, document.getElementById('root')); 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Timeline from './timeline'; 4 | export default Timeline; 5 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import {expect} from 'chai'; 4 | 5 | import jsdom from 'jsdom'; 6 | 7 | let consoleEr = ''; 8 | let consoleWa = ''; 9 | 10 | function setUpDomEnvironment() { 11 | const {JSDOM} = jsdom; 12 | const dom = new JSDOM('', {url: 'http://localhost/'}); 13 | const {window} = dom; 14 | 15 | global.window = window; 16 | global.document = window.document; 17 | global.navigator = { 18 | userAgent: 'node.js' 19 | }; 20 | global.console.error = e => (consoleEr += e); 21 | global.console.warn = e => (consoleWa += e); 22 | global.console.warning = e => (consoleWa += e); 23 | copyProps(window, global); 24 | } 25 | 26 | function copyProps(src, target) { 27 | const props = Object.getOwnPropertyNames(src) 28 | .filter(prop => typeof target[prop] === 'undefined') 29 | .map(prop => Object.getOwnPropertyDescriptor(src, prop)); 30 | Object.defineProperties(target, props); 31 | } 32 | 33 | beforeEach(function() { 34 | consoleEr = ''; 35 | consoleWa = ''; 36 | }); 37 | afterEach(function() { 38 | expect(consoleEr).to.equal(''); 39 | expect(consoleWa).to.equal(''); 40 | }); 41 | setUpDomEnvironment(); 42 | Enzyme.configure({adapter: new Adapter()}); 43 | -------------------------------------------------------------------------------- /src/stories/Timeline.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | 4 | import DemoTimeline from '../demo'; 5 | import Timeline from '../timeline'; 6 | import {Alert} from 'antd'; 7 | 8 | export default { 9 | title: 'Timeline' 10 | }; 11 | 12 | export const OriginalDemo = () => ; 13 | 14 | const humanResources = [ 15 | {id: 0, title: 'John Doe'}, 16 | {id: 1, title: 'Alex Randal'}, 17 | {id: 2, title: 'Mary Danton'} 18 | ]; 19 | const tasksWithMoment = [ 20 | {key: 0, row: 0, title: 'T1', color: 'red', start: moment('2018-09-20 8:00'), end: moment('2018-09-20 9:00')}, 21 | {key: 1, row: 0, title: 'T2', color: 'red', start: moment('2018-09-20 18:00'), end: moment('2018-09-20 19:00')}, 22 | {key: 2, row: 0, title: 'T3', color: 'red', start: moment('2018-09-20 20:00'), end: moment('2018-09-20 21:00')}, 23 | {key: 3, row: 1, title: 'T1', color: 'yellow', start: moment('2018-09-20 7:00'), end: moment('2018-09-20 8:00')}, 24 | {key: 4, row: 1, title: 'T2', color: 'yellow', start: moment('2018-09-20 17:00'), end: moment('2018-09-20 20:00')}, 25 | {key: 5, row: 1, title: 'T3', color: 'yellow', start: moment('2018-09-20 19:00'), end: moment('2018-09-20 20:00')}, 26 | {key: 6, row: 2, title: 'T1', color: 'blue', start: moment('2018-09-20 8:00'), end: moment('2018-09-20 10:00')}, 27 | {key: 7, row: 2, title: 'T2', color: 'blue', start: moment('2018-09-20 18:00'), end: moment('2018-09-20 20:00')}, 28 | {key: 8, row: 2, title: 'T3', color: 'blue', start: moment('2018-09-20 20:00'), end: moment('2018-09-20 21:00')} 29 | ]; 30 | 31 | // we convert start and end from the moment object to a raw timestamp (millis) 32 | // reminder: moment.valueOf() returns a raw timestamp (a number of millis) 33 | const tasksWithoutMoment = tasksWithMoment.map(t => { 34 | return {...t, start: t.start.valueOf(), end: t.end.valueOf()}; 35 | }); 36 | 37 | export const BasicUsageWithoutMoment = () => ( 38 |
39 | 42 |

43 | The Timeline was originally designed to handle date/times w/ Moment.js, 44 | a popular lib. However there are 2 drawbacks. #1: even the authors/maintainers of Moment.js don't 45 | quite recommend it any more for use with new projects. The major 46 | complain seems to be the mutability of "moment" objects. #2: "moment" objects are not friendly with{' '} 47 | Redux, a popular framework for state management. Many folk use React for 48 | state management. And not being able to store the state that feeds the Timeline is a big drawback, since 49 | additional conversions are necessary. 50 |

51 |

52 | The{' '} 53 | 54 | property useMoment 55 | {' '} 56 | to the rescue! If false, then you when you "talk" date/times to the Timeline, then you use 57 | plain timestamps (i.e. number of millis, e.g. new Date().valueOf()). And this everywhere where 58 | a date/time is needed (e.g. for an item, for global start/end, etc.). This is the recommended way to 59 | go, especially if you use Redux. But be aware that this property is by default true, in order 60 | to maintain backward compatibility. 61 |

62 |

63 | NOTE: the Timeline still uses "moment" internally. And this because it was quicker to refactor this way. 64 | This may change in the future, if we find reasons and time to refactor more. 65 |

66 | 67 | } 68 | /> 69 | 77 |
78 | ); 79 | 80 | export const BasicUsageWithMoment = () => ( 81 |
82 | 89 |
90 | ); 91 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .rct9k-timeline-div { 2 | width: 100%; 3 | /* if the timeline is in a flex layout, we want it to adapt to the parent */ 4 | flex-grow: 1; 5 | /* if the user just added the timeline to his app, maybe he didn't provide a flex parent; w/o this, he would see segments at all; 6 | w/ this, he sees at least something, and then will think of improving the parent layout 7 | */ 8 | min-height: 200px; 9 | background-color: #a5a5a5; 10 | -webkit-user-select: none; 11 | -moz-user-select: none; 12 | -ms-user-select: none; 13 | user-select: none; 14 | } 15 | 16 | /* Timeline items */ 17 | .rct9k-items-outer { 18 | white-space: nowrap; 19 | position: absolute; 20 | display: inline-block; 21 | overflow: hidden; 22 | z-index: 3; 23 | } 24 | .rct9k-items-inner { 25 | display: inline-block; 26 | margin: 3px; 27 | padding: 3px; 28 | width: 100%; 29 | } 30 | .rct9k-marker-overlay { 31 | z-index: 2; 32 | height: 475px; 33 | position: absolute; 34 | width: 2px; 35 | background: red; 36 | pointer-events: none; 37 | } 38 | .rct9k-item-renderer-inner { 39 | pointer-events: none; 40 | } 41 | .rct9k-row { 42 | /* white-space: nowrap; */ 43 | box-sizing: border-box; 44 | border: 1px solid black; 45 | border-top: 0; 46 | border-left: 2px solid black; 47 | } 48 | 49 | .rct9k-items-selected { 50 | background-color: magenta; 51 | } 52 | 53 | .ReactVirtualized__Grid { 54 | overflow-x: hidden; 55 | } 56 | 57 | .ReactVirtualized__Grid__innerScrollContainer:first-child { 58 | border-top: 1px solid black; 59 | } 60 | 61 | /* Timeline groups */ 62 | .rct9k-group { 63 | text-align: center; 64 | box-sizing: border-box; 65 | border: 1px solid black; 66 | border-top: 0; 67 | border-right: 0; 68 | background-color: white; 69 | z-index: 6; 70 | } 71 | 72 | .rct9k-timebar { 73 | display: flex; 74 | } 75 | 76 | /* Timeline top timebar */ 77 | .rct9k-timebar-outer, 78 | .rct9k-timebar-group-title { 79 | height: 60px; 80 | background-color: white; 81 | border: 1px solid black; 82 | box-sizing: border-box; 83 | } 84 | .rct9k-timebar-outer { 85 | border-left: none; 86 | } 87 | .rct9k-timebar-inner { 88 | height: 30px; 89 | white-space: nowrap; 90 | overflow: hidden; 91 | } 92 | .rct9k-timebar-item { 93 | text-align: center; 94 | display: inline-block; 95 | border-left: 1px solid; 96 | white-space: nowrap; 97 | box-sizing: border-box; 98 | } 99 | .rct9k-timebar-item-selected { 100 | background-color: lightblue; 101 | } 102 | 103 | .rct9k-timebar-group-title { 104 | border-right: none; 105 | } 106 | 107 | /* Multi-select box */ 108 | .rct9k-selector-outer { 109 | display: inline-block; 110 | background-color: #5bb3ff80; 111 | width: 1px; 112 | height: 1px; 113 | position: fixed; 114 | z-index: 5; 115 | } 116 | 117 | /* Row layer */ 118 | .rct9k-row-layer { 119 | pointer-events: none; 120 | position: absolute; 121 | z-index: 1; 122 | } 123 | 124 | #root { 125 | width: 100%; 126 | height: 100%; 127 | } 128 | 129 | .demo { 130 | height: 100%; 131 | display: flex; 132 | gap: 5px; 133 | flex-direction: column; 134 | } 135 | -------------------------------------------------------------------------------- /src/timeline.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import ReactDOM from 'react-dom'; 6 | import {Grid, AutoSizer, defaultCellRangeRenderer} from 'react-virtualized'; 7 | 8 | import moment from 'moment'; 9 | import interact from 'interactjs'; 10 | import _ from 'lodash'; 11 | 12 | import {pixToInt, intToPix, sumStyle} from './utils/commonUtils'; 13 | import { 14 | rowItemsRenderer, 15 | rowLayerRenderer, 16 | getNearestRowNumber, 17 | getNearestRowObject, 18 | getMaxOverlappingItems, 19 | getTrueBottom, 20 | getVerticalMarginBorder, 21 | getRowObjectRowNumber 22 | } from './utils/itemUtils'; 23 | import { 24 | timeSnap, 25 | getTimeAtPixel, 26 | getPixelAtTime, 27 | getSnapPixelFromDelta, 28 | pixelsPerMillisecond, 29 | convertDateToMoment, 30 | convertMomentToDateType 31 | } from './utils/timeUtils'; 32 | import Timebar from './components/timebar'; 33 | import SelectBox from './components/selector'; 34 | import {DefaultGroupRenderer, DefaultItemRenderer} from './components/renderers'; 35 | import TimelineBody from './components/body'; 36 | import Marker from './components/marker'; 37 | 38 | // startsWith polyfill for IE11 support 39 | import 'core-js/fn/string/starts-with'; 40 | 41 | const SINGLE_COLUMN_LABEL_PROPERTY = 'title'; 42 | 43 | /** 44 | * Timeline class 45 | * @reactProps {!number} items - this is prop1 46 | * @reactProps {string} prop2 - this is prop2 47 | */ 48 | export default class Timeline extends React.Component { 49 | /** 50 | * @type {object} 51 | */ 52 | static TIMELINE_MODES = Object.freeze({ 53 | SELECT: 1, 54 | DRAG: 2, 55 | RESIZE: 4 56 | }); 57 | 58 | static propTypes = { 59 | items: PropTypes.arrayOf( 60 | PropTypes.shape({ 61 | key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, 62 | // start and end are not required because getStartFromItem() and getEndFromItem() functions 63 | // are being used and they can be overriden to use other fields 64 | start: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), 65 | end: PropTypes.oneOfType([PropTypes.object, PropTypes.number]) 66 | }) 67 | ).isRequired, 68 | groups: PropTypes.arrayOf(PropTypes.object).isRequired, 69 | // Single column mode: the width of the column. 70 | // Multiple columns mode: the default width of the columns, which may be overridden on a per column basis. 71 | groupOffset: PropTypes.number.isRequired, 72 | tableColumns: PropTypes.arrayOf( 73 | PropTypes.shape({ 74 | // The default renderer for a cell is props.groupRenderer that renders labelProperty from group. 75 | // The renderer for a column can be overriden using cellRenderer. cellRenderer can be a React element 76 | // or a function or a class component that generates a React element. 77 | labelProperty: PropTypes.string, 78 | cellRenderer: PropTypes.oneOfType([PropTypes.func, PropTypes.element]), 79 | // The default renderer for a header is props.groupTitleRenderer that renders headerLabel. 80 | // The renderer for a header column can be overriden using headerRenderer. headerRenderer can be a React element 81 | // or a function or a class component that generates a React element. 82 | headerLabel: PropTypes.string, 83 | headerRenderer: PropTypes.oneOfType([PropTypes.func, PropTypes.element]), 84 | width: PropTypes.number // width of the column in px 85 | }) 86 | ), 87 | rowLayers: PropTypes.arrayOf( 88 | PropTypes.shape({ 89 | // start and end are not required because getStartFromItem() and getEndFromItem() functions 90 | // are being used and they can be overriden to use other fields 91 | start: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), 92 | end: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), 93 | rowNumber: PropTypes.number.isRequired, 94 | style: PropTypes.object.isRequired 95 | }) 96 | ), 97 | selectedItems: PropTypes.arrayOf(PropTypes.number), 98 | startDate: PropTypes.oneOfType([PropTypes.object, PropTypes.number]).isRequired, 99 | endDate: PropTypes.oneOfType([PropTypes.object, PropTypes.number]).isRequired, 100 | snap: PropTypes.number, //like snapMinutes, but for seconds; couldn't get it any lower because the pixels are not calculated correctly 101 | snapMinutes: PropTypes.number, 102 | showCursorTime: PropTypes.bool, 103 | cursorTimeFormat: PropTypes.string, 104 | componentId: PropTypes.string, // A unique key to identify the component. Only needed when 2 grids are mounted 105 | itemHeight: PropTypes.number, 106 | timelineMode: PropTypes.number, 107 | timebarFormat: PropTypes.object, 108 | onItemClick: PropTypes.func, 109 | onItemDoubleClick: PropTypes.func, 110 | onItemContext: PropTypes.func, 111 | onInteraction: PropTypes.func, 112 | onRowClick: PropTypes.func, 113 | onRowContext: PropTypes.func, 114 | onRowDoubleClick: PropTypes.func, 115 | onItemHover: PropTypes.func, 116 | onItemLeave: PropTypes.func, 117 | itemRenderer: PropTypes.func, 118 | // Single column mode: the renderer of a cell. 119 | // Multiple columns mode: the default renderer of a cell, which may be overridden on a per column basis. 120 | groupRenderer: PropTypes.func, 121 | // Single column mode: the renderer of the header cell. 122 | // Multiple columns mode: the default renderer of a header cell, which may be overridden on a per column basis. 123 | groupTitleRenderer: PropTypes.func, 124 | shallowUpdateCheck: PropTypes.bool, 125 | forceRedrawFunc: PropTypes.func, 126 | bottomResolution: PropTypes.string, 127 | topResolution: PropTypes.string, 128 | interactOptions: PropTypes.shape({ 129 | draggable: PropTypes.object, 130 | pointerEvents: PropTypes.object, 131 | // TODO: this doesn't seem used; originally it was w/ "required"; I removed this to avoid warnings in console 132 | resizable: PropTypes.object 133 | }), 134 | useMoment: PropTypes.bool // Whether the timeline should receive dates as moment object or in milliseconds. 135 | }; 136 | 137 | static defaultProps = { 138 | rowLayers: [], 139 | groupOffset: 150, 140 | itemHeight: 40, 141 | snapMinutes: 15, 142 | cursorTimeFormat: 'D MMM YYYY HH:mm', 143 | componentId: 'r9k1', 144 | showCursorTime: true, 145 | groupRenderer: DefaultGroupRenderer, 146 | itemRenderer: DefaultItemRenderer, 147 | timelineMode: Timeline.TIMELINE_MODES.SELECT | Timeline.TIMELINE_MODES.DRAG | Timeline.TIMELINE_MODES.RESIZE, 148 | shallowUpdateCheck: false, 149 | forceRedrawFunc: null, 150 | onItemHover() {}, 151 | onItemLeave() {}, 152 | interactOptions: {}, 153 | useMoment: true, 154 | tableColumns: [] 155 | }; 156 | 157 | /** 158 | * The types of interactions - see {@link onInteraction} 159 | */ 160 | static changeTypes = { 161 | resizeStart: 'resizeStart', 162 | resizeEnd: 'resizeEnd', 163 | dragEnd: 'dragEnd', 164 | dragStart: 'dragStart', 165 | itemsSelected: 'itemsSelected', 166 | snappedMouseMove: 'snappedMouseMove' 167 | }; 168 | 169 | /** 170 | * Checks if the given bit is set in the given mask 171 | * @param {number} bit Bit to check 172 | * @param {number} mask Mask to check against 173 | * @returns {boolean} True if bit is set; else false 174 | */ 175 | static isBitSet(bit, mask) { 176 | return (bit & mask) === bit; 177 | } 178 | 179 | /** 180 | * Alias for no op function 181 | */ 182 | static no_op = () => {}; 183 | 184 | constructor(props) { 185 | super(props); 186 | this.selecting = false; 187 | this.state = {selection: [], cursorTime: null}; 188 | 189 | // These functions need to be bound because they are passed as parameters. 190 | // getStartFromItem and getEndFromItem are used in rowItemsRenderer function 191 | // to obtain the start and end of the rendered items. 192 | this.getStartFromItem = this.getStartFromItem.bind(this); 193 | this.getEndFromItem = this.getEndFromItem.bind(this); 194 | // getStartFromRowLayer and getEndFromRowLayer are used in rowLayerRenderer 195 | // to obtain the start and end of the rendered row layers. 196 | this.getStartFromRowLayer = this.getStartFromRowLayer.bind(this); 197 | this.getEndFromRowLayer = this.getEndFromRowLayer.bind(this); 198 | 199 | this.setTimeMap(this.props.items); 200 | 201 | this.cellRenderer = this.cellRenderer.bind(this); 202 | this.rowHeight = this.rowHeight.bind(this); 203 | this.setTimeMap = this.setTimeMap.bind(this); 204 | this.getItem = this.getItem.bind(this); 205 | this.changeGroup = this.changeGroup.bind(this); 206 | this.setSelection = this.setSelection.bind(this); 207 | this.clearSelection = this.clearSelection.bind(this); 208 | this.getTimelineWidth = this.getTimelineWidth.bind(this); 209 | this.itemFromElement = this.itemFromElement.bind(this); 210 | this.updateDimensions = this.updateDimensions.bind(this); 211 | this.grid_ref_callback = this.grid_ref_callback.bind(this); 212 | this.select_ref_callback = this.select_ref_callback.bind(this); 213 | this.throttledMouseMoveFunc = _.throttle(this.throttledMouseMoveFunc.bind(this), 20); 214 | this.mouseMoveFunc = this.mouseMoveFunc.bind(this); 215 | this.getCursor = this.getCursor.bind(this); 216 | 217 | const canSelect = Timeline.isBitSet(Timeline.TIMELINE_MODES.SELECT, this.props.timelineMode); 218 | const canDrag = Timeline.isBitSet(Timeline.TIMELINE_MODES.DRAG, this.props.timelineMode); 219 | const canResize = Timeline.isBitSet(Timeline.TIMELINE_MODES.RESIZE, this.props.timelineMode); 220 | this.setUpDragging(canSelect, canDrag, canResize); 221 | } 222 | 223 | componentDidMount() { 224 | window.addEventListener('resize', this.updateDimensions); 225 | } 226 | 227 | componentWillReceiveProps(nextProps) { 228 | this.setTimeMap( 229 | nextProps.items, 230 | convertDateToMoment(nextProps.startDate, nextProps.useMoment), 231 | convertDateToMoment(nextProps.endDate, nextProps.useMoment), 232 | nextProps.useMoment 233 | ); 234 | // @TODO 235 | // investigate if we need this, only added to refresh the grid 236 | // when double click -> add an item 237 | this.refreshGrid(); 238 | } 239 | 240 | componentWillUnmount() { 241 | if (this._itemInteractable) this._itemInteractable.unset(); 242 | if (this._selectRectangleInteractable) this._selectRectangleInteractable.unset(); 243 | 244 | window.removeEventListener('resize', this.updateDimensions); 245 | } 246 | 247 | componentDidUpdate(prevProps, prevState) { 248 | const {timelineMode, selectedItems} = this.props; 249 | const selectionChange = !_.isEqual(prevProps.selectedItems, selectedItems); 250 | const timelineModeChange = !_.isEqual(prevProps.timelineMode, timelineMode); 251 | 252 | if (timelineModeChange || selectionChange) { 253 | const canSelect = Timeline.isBitSet(Timeline.TIMELINE_MODES.SELECT, timelineMode); 254 | const canDrag = Timeline.isBitSet(Timeline.TIMELINE_MODES.DRAG, timelineMode); 255 | const canResize = Timeline.isBitSet(Timeline.TIMELINE_MODES.RESIZE, timelineMode); 256 | this.setUpDragging(canSelect, canDrag, canResize); 257 | } 258 | } 259 | 260 | /** 261 | * It returns the start date of the timeline as moment. 262 | * @returns startDate as moment 263 | */ 264 | getStartDate() { 265 | return convertDateToMoment(this.props.startDate, this.props.useMoment); 266 | } 267 | 268 | /** 269 | * It returns the end date of the timeline as moment. 270 | * @returns endDate as moment 271 | */ 272 | getEndDate() { 273 | return convertDateToMoment(this.props.endDate, this.props.useMoment); 274 | } 275 | 276 | /** 277 | * It returns the start of the item as moment. 278 | * @param {object} item Item that is displayed in the grid. 279 | * @param {useMoment} useMoment This parameter is necessary because this method is also called when 280 | * the component receives new props. Default value: this.props.useMoment. 281 | * @returns start of the item as moment 282 | */ 283 | getStartFromItem(item, useMoment = this.props.useMoment) { 284 | return convertDateToMoment(item.start, useMoment); 285 | } 286 | 287 | /** 288 | * It assigns newDateAsMoment to the start of the item, but first it converts newDateAsMoment 289 | * to moment or milliseconds according to useMoment. 290 | * @param {object} item Item that is displayed in the grid. 291 | * @param {moment} newDateAsMoment 292 | */ 293 | setStartToItem(item, newDateAsMoment) { 294 | item.start = convertMomentToDateType(newDateAsMoment, this.props.useMoment); 295 | } 296 | 297 | /** 298 | * It returns the end of the item as moment. 299 | * @param {object} item Item that is displayed in the grid. 300 | * @param {useMoment} useMoment This parameter is necessary because this method is also called when 301 | * the component receives new props. Default value: this.props.useMoment. 302 | * @returns end of the item as moment. 303 | */ 304 | getEndFromItem(item, useMoment = this.props.useMoment) { 305 | return convertDateToMoment(item.end, useMoment); 306 | } 307 | 308 | /** 309 | * It assigns newDateAsMoment to the end of the item, but first it converts newDateAsMoment 310 | * to moment or milliseconds according to useMoment. 311 | * @param {object} item Item that is displayed in the grid. 312 | * @param {moment} newDateAsMoment 313 | */ 314 | setEndToItem(item, newDateAsMoment) { 315 | item.end = convertMomentToDateType(newDateAsMoment, this.props.useMoment); 316 | } 317 | 318 | /** 319 | * It returns the start of the layer as moment. 320 | * @param {object} layer 321 | * @returns the start of the rowLayer as moment. 322 | */ 323 | getStartFromRowLayer(layer) { 324 | return convertDateToMoment(layer.start, this.props.useMoment); 325 | } 326 | 327 | /** 328 | * It assigns newDateAsMoment to the start of the layer, but first it converts newDateAsMoment 329 | * to moment or milliseconds according to useMoment. 330 | * @param {object} layer Item that is displayed in the grid. 331 | * @param {moment} newDateAsMoment 332 | */ 333 | setStartToRowLayer(layer, newDateAsMoment) { 334 | layer.start = convertMomentToDateType(newDateAsMoment, this.props.useMoment); 335 | } 336 | 337 | /** 338 | * It returns the end of the layer as moment. 339 | * @param {object} layer 340 | * @returns the end of the layer as moment. 341 | */ 342 | getEndFromRowLayer(layer) { 343 | return convertDateToMoment(layer.end, this.props.useMoment); 344 | } 345 | 346 | /** 347 | * It assigns newDateAsMoment to the end of the layer, but first it converts newDateAsMoment 348 | * to moment or milliseconds according to useMoment. 349 | * @param {object} layer Item that is displayed in the grid. 350 | * @param {moment} newDateAsMoment 351 | */ 352 | setEndToRowLayer(layer, newDateAsMoment) { 353 | layer.end = convertMomentToDateType(newDateAsMoment, this.props.useMoment); 354 | } 355 | 356 | /** 357 | * Re-renders the grid when the window or container is resized 358 | */ 359 | updateDimensions() { 360 | clearTimeout(this.resizeTimeout); 361 | this.resizeTimeout = setTimeout(() => { 362 | this.forceUpdate(); 363 | this._grid.recomputeGridSize(); 364 | }, 100); 365 | } 366 | 367 | /** 368 | * Sets the internal maps used by the component for looking up item & row data 369 | * @param {Object[]} items The items to be displayed in the grid 370 | * @param {moment} startDate The visible start date of the timeline 371 | * @param {moment} endDate The visible end date of the timeline 372 | * @param {boolean} useMoment This parameter is necessary because this method is also called when 373 | * the component receives new props. 374 | */ 375 | setTimeMap(items, startDate, endDate, useMoment) { 376 | if (!startDate || !endDate) { 377 | startDate = this.getStartDate(); 378 | endDate = this.getEndDate(); 379 | } 380 | this.itemRowMap = {}; // timeline elements (key) => (rowNo). 381 | this.rowItemMap = {}; // (rowNo) => timeline elements 382 | this.rowHeightCache = {}; // (rowNo) => max number of stacked items 383 | let visibleItems = _.filter(items, i => { 384 | return this.getEndFromItem(i, useMoment) > startDate && this.getStartFromItem(i, useMoment) < endDate; 385 | }); 386 | let itemRows = _.groupBy(visibleItems, 'row'); 387 | _.forEach(itemRows, (visibleItems, row) => { 388 | const rowInt = parseInt(row); 389 | if (this.rowItemMap[rowInt] === undefined) this.rowItemMap[rowInt] = []; 390 | _.forEach(visibleItems, item => { 391 | this.itemRowMap[item.key] = rowInt; 392 | this.rowItemMap[rowInt].push(item); 393 | }); 394 | this.rowHeightCache[rowInt] = getMaxOverlappingItems( 395 | visibleItems, 396 | this.getStartFromItem, 397 | this.getEndFromItem, 398 | useMoment 399 | ); 400 | }); 401 | } 402 | 403 | /** 404 | * Returns an item given its DOM element 405 | * @param {Object} e the DOM element of the item 406 | * @return {Object} Item details 407 | * @prop {number|string} index The item's index 408 | * @prop {number} rowNo The row number the item is in 409 | * @prop {number} itemIndex Not really used - gets the index of the item in the row map 410 | * @prop {Object} item The provided item object 411 | */ 412 | itemFromElement(e) { 413 | const index = e.getAttribute('data-item-index'); 414 | const rowNo = this.itemRowMap[index]; 415 | const itemIndex = _.findIndex(this.rowItemMap[rowNo], i => i.key == index); 416 | const item = this.rowItemMap[rowNo][itemIndex]; 417 | 418 | return {index, rowNo, itemIndex, item}; 419 | } 420 | 421 | /** 422 | * Gets an item given its ID 423 | * @param {number} id item id 424 | * @return {Object} Item object 425 | */ 426 | getItem(id) { 427 | // This is quite stupid and shouldn't really be needed 428 | const rowNo = this.itemRowMap[id]; 429 | const itemIndex = _.findIndex(this.rowItemMap[rowNo], i => i.key == id); 430 | return this.rowItemMap[rowNo][itemIndex]; 431 | } 432 | 433 | /** 434 | * Move an item from one row to another 435 | * @param {object} item The item object whose groups is to be changed 436 | * @param {number} curRow The item's current row index 437 | * @param {number} newRow The item's new row index 438 | */ 439 | changeGroup(item, curRow, newRow) { 440 | item.row = newRow; 441 | this.itemRowMap[item.key] = newRow; 442 | this.rowItemMap[curRow] = this.rowItemMap[curRow].filter(i => i.key !== item.key); 443 | this.rowItemMap[newRow].push(item); 444 | } 445 | 446 | /** 447 | * Set the currently selected time ranges (for the timebar to display) 448 | * @param {Object[]} selections Of the form `[[start, end], [start, end], ...]` 449 | */ 450 | setSelection(selections) { 451 | let newSelection = _.map(selections, s => { 452 | return {start: s[0].clone(), end: s[1].clone()}; 453 | }); 454 | this.setState({selection: newSelection}); 455 | } 456 | 457 | /** 458 | * Clears the currently selected time range state 459 | */ 460 | clearSelection() { 461 | this.setState({selection: []}); 462 | } 463 | 464 | /** 465 | * Get the width of the timeline NOT including the left group list 466 | * @param {?number} totalWidth Total timeline width. If not supplied we use the timeline ref 467 | * @returns {number} The width in pixels 468 | */ 469 | getTimelineWidth(totalWidth) { 470 | if (totalWidth !== undefined) return totalWidth - this.calculateLeftOffset(); 471 | return this._grid.props.width - this.calculateLeftOffset(); 472 | } 473 | 474 | /** 475 | * Get the snap in milliseconds from snapMinutes or snap 476 | */ 477 | getTimelineSnap() { 478 | if (this.props.snap) { 479 | return this.props.snap * 1000; 480 | } else if (this.props.snapMinutes) { 481 | return this.props.snapMinutes * 60 * 1000; 482 | } 483 | return 1; 484 | } 485 | 486 | /** 487 | * re-computes the grid's row sizes 488 | * @param {Object?} config Config to pass wo react-virtualized's compute func 489 | */ 490 | refreshGrid = (config = {}) => { 491 | this._grid.recomputeGridSize(config); 492 | }; 493 | 494 | setUpDragging(canSelect, canDrag, canResize) { 495 | // No need to setUpDragging during SSR 496 | if (typeof window === 'undefined') { 497 | return; 498 | } 499 | 500 | const topDivClassId = `rct9k-id-${this.props.componentId}`; 501 | const selectedItemSelector = '.rct9k-items-outer-selected'; 502 | if (this._itemInteractable) this._itemInteractable.unset(); 503 | if (this._selectRectangleInteractable) this._selectRectangleInteractable.unset(); 504 | 505 | this._itemInteractable = interact(`.${topDivClassId} .item_draggable`); 506 | this._selectRectangleInteractable = interact(`.${topDivClassId} .parent-div`); 507 | 508 | this._itemInteractable.pointerEvents(this.props.interactOptions.pointerEvents).on('tap', e => { 509 | this._handleItemRowEvent(e, this.props.onItemClick, this.props.onRowClick); 510 | }); 511 | 512 | if (canDrag) { 513 | this._itemInteractable 514 | .draggable({ 515 | enabled: true, 516 | allowFrom: selectedItemSelector, 517 | restrict: { 518 | restriction: `.${topDivClassId}`, 519 | elementRect: {left: 0, right: 1, top: 0, bottom: 1} 520 | }, 521 | ...this.props.interactOptions.draggable 522 | }) 523 | .on('dragstart', e => { 524 | let selections = []; 525 | const animatedItems = 526 | this.props.onInteraction && 527 | this.props.onInteraction(Timeline.changeTypes.dragStart, null, this.props.selectedItems); 528 | 529 | _.forEach(animatedItems, id => { 530 | let domItem = this._gridDomNode.querySelector("span[data-item-index='" + id + "'"); 531 | if (domItem) { 532 | selections.push([this.getStartFromItem(this.getItem(id)), this.getEndFromItem(this.getItem(id))]); 533 | domItem.setAttribute('isDragging', 'True'); 534 | domItem.setAttribute('drag-x', 0); 535 | domItem.setAttribute('drag-y', 0); 536 | domItem.style['z-index'] = 4; 537 | } 538 | }); 539 | this.setSelection(selections); 540 | }) 541 | .on('dragmove', e => { 542 | const target = e.target; 543 | let animatedItems = this._gridDomNode.querySelectorAll("span[isDragging='True'") || []; 544 | 545 | let dx = (parseFloat(target.getAttribute('drag-x')) || 0) + e.dx; 546 | let dy = (parseFloat(target.getAttribute('drag-y')) || 0) + e.dy; 547 | let selections = []; 548 | 549 | // Snap the movement to the current snap interval 550 | const snapDx = getSnapPixelFromDelta( 551 | dx, 552 | this.getStartDate(), 553 | this.getEndDate(), 554 | this.getTimelineWidth(), 555 | this.getTimelineSnap() 556 | ); 557 | 558 | _.forEach(animatedItems, domItem => { 559 | const {item} = this.itemFromElement(domItem); 560 | let itemDuration = this.getEndFromItem(item).diff(this.getStartFromItem(item)); 561 | let newPixelOffset = pixToInt(domItem.style.left) + snapDx; 562 | let newStart = getTimeAtPixel( 563 | newPixelOffset, 564 | this.getStartDate(), 565 | this.getEndDate(), 566 | this.getTimelineWidth(), 567 | this.getTimelineSnap() 568 | ); 569 | 570 | let newEnd = newStart.clone().add(itemDuration); 571 | selections.push([newStart, newEnd]); 572 | 573 | // Translate the new start time back to pixels, so we can animate the snap 574 | domItem.style.webkitTransform = domItem.style.transform = 'translate(' + snapDx + 'px, ' + dy + 'px)'; 575 | }); 576 | 577 | target.setAttribute('drag-x', dx); 578 | target.setAttribute('drag-y', dy); 579 | 580 | this.setSelection(selections); 581 | }) 582 | .on('dragend', e => { 583 | const {item, rowNo} = this.itemFromElement(e.target); 584 | let animatedItems = this._gridDomNode.querySelectorAll("span[isDragging='True'") || []; 585 | 586 | this.setSelection([[this.getStartFromItem(item), this.getEndFromItem(item)]]); 587 | this.clearSelection(); 588 | 589 | // Change row 590 | let newRow = getNearestRowNumber(e.clientX, e.clientY); 591 | 592 | let rowChangeDelta = newRow - rowNo; 593 | // Update time 594 | let newPixelOffset = pixToInt(e.target.style.left) + (parseFloat(e.target.getAttribute('drag-x')) || 0); 595 | let newStart = getTimeAtPixel( 596 | newPixelOffset, 597 | this.getStartDate(), 598 | this.getEndDate(), 599 | this.getTimelineWidth(), 600 | this.getTimelineSnap() 601 | ); 602 | 603 | const timeDelta = newStart.clone().diff(this.getStartFromItem(item), 'minutes'); 604 | const changes = {rowChangeDelta, timeDelta}; 605 | let items = []; 606 | 607 | // Default, all items move by the same offset during a drag 608 | _.forEach(animatedItems, domItem => { 609 | const {item, rowNo} = this.itemFromElement(domItem); 610 | 611 | let itemDuration = this.getEndFromItem(item).diff(this.getStartFromItem(item)); 612 | let newStart = this.getStartFromItem(item) 613 | .clone() 614 | .add(timeDelta, 'minutes'); 615 | let newEnd = newStart.clone().add(itemDuration); 616 | this.setStartToItem(item, newStart); 617 | this.setEndToItem(item, newEnd); 618 | if (rowChangeDelta < 0) { 619 | item.row = Math.max(0, item.row + rowChangeDelta); 620 | } else if (rowChangeDelta > 0) { 621 | item.row = Math.min(this.props.groups.length - 1, item.row + rowChangeDelta); 622 | } 623 | 624 | items.push(item); 625 | }); 626 | 627 | this.props.onInteraction && this.props.onInteraction(Timeline.changeTypes.dragEnd, changes, items); 628 | 629 | // Reset the styles 630 | animatedItems.forEach(domItem => { 631 | domItem.style.webkitTransform = domItem.style.transform = 'translate(0px, 0px)'; 632 | domItem.setAttribute('drag-x', 0); 633 | domItem.setAttribute('drag-y', 0); 634 | domItem.style['z-index'] = 3; 635 | domItem.style['top'] = intToPix( 636 | this.props.itemHeight * Math.round(pixToInt(domItem.style['top']) / this.props.itemHeight) 637 | ); 638 | domItem.removeAttribute('isDragging'); 639 | }); 640 | 641 | this._grid.recomputeGridSize({rowIndex: 0}); 642 | }); 643 | } 644 | if (canResize) { 645 | this._itemInteractable 646 | .resizable({ 647 | allowFrom: selectedItemSelector, 648 | edges: {left: true, right: true, bottom: false, top: false}, 649 | ...this.props.interactOptions.draggable 650 | }) 651 | .on('resizestart', e => { 652 | const selected = 653 | this.props.onInteraction && 654 | this.props.onInteraction(Timeline.changeTypes.resizeStart, null, this.props.selectedItems); 655 | _.forEach(selected, id => { 656 | let domItem = this._gridDomNode.querySelector("span[data-item-index='" + id + "'"); 657 | if (domItem) { 658 | domItem.setAttribute('isResizing', 'True'); 659 | domItem.setAttribute('initialWidth', pixToInt(domItem.style.width)); 660 | domItem.style['z-index'] = 4; 661 | } 662 | }); 663 | }) 664 | .on('resizemove', e => { 665 | let animatedItems = this._gridDomNode.querySelectorAll("span[isResizing='True'") || []; 666 | 667 | let dx = parseFloat(e.target.getAttribute('delta-x')) || 0; 668 | dx += e.deltaRect.left; 669 | 670 | let dw = e.rect.width - Number(e.target.getAttribute('initialWidth')); 671 | 672 | const minimumWidth = 673 | pixelsPerMillisecond(this.getStartDate(), this.getEndDate(), this.getTimelineWidth()) * 674 | this.getTimelineSnap(); 675 | 676 | const snappedDx = getSnapPixelFromDelta( 677 | dx, 678 | this.getStartDate(), 679 | this.getEndDate(), 680 | this.getTimelineWidth(), 681 | this.getTimelineSnap() 682 | ); 683 | 684 | const snappedDw = getSnapPixelFromDelta( 685 | dw, 686 | this.getStartDate(), 687 | this.getEndDate(), 688 | this.getTimelineWidth(), 689 | this.getTimelineSnap() 690 | ); 691 | 692 | _.forEach(animatedItems, item => { 693 | item.style.width = intToPix(Number(item.getAttribute('initialWidth')) + snappedDw + minimumWidth); 694 | item.style.webkitTransform = item.style.transform = 'translate(' + snappedDx + 'px, 0px)'; 695 | }); 696 | e.target.setAttribute('delta-x', dx); 697 | }) 698 | .on('resizeend', e => { 699 | let animatedItems = this._gridDomNode.querySelectorAll("span[isResizing='True'") || []; 700 | // Update time 701 | const dx = parseFloat(e.target.getAttribute('delta-x')) || 0; 702 | const isStartTimeChange = dx != 0; 703 | 704 | let items = []; 705 | let minRowNo = Infinity; 706 | 707 | let durationChange = null; 708 | // Calculate the default item positions 709 | _.forEach(animatedItems, domItem => { 710 | let startPixelOffset = pixToInt(domItem.style.left) + dx; 711 | const {item, rowNo} = this.itemFromElement(domItem); 712 | 713 | minRowNo = Math.min(minRowNo, rowNo); 714 | 715 | if (isStartTimeChange) { 716 | let newStart = getTimeAtPixel( 717 | startPixelOffset, 718 | this.getStartDate(), 719 | this.getEndDate(), 720 | this.getTimelineWidth(), 721 | this.getTimelineSnap() 722 | ); 723 | if (durationChange === null) durationChange = this.getStartFromItem(item).diff(newStart, 'minutes'); 724 | this.setStartToItem(item, newStart); 725 | } else { 726 | let endPixelOffset = startPixelOffset + pixToInt(domItem.style.width); 727 | let newEnd = getTimeAtPixel( 728 | endPixelOffset, 729 | this.getStartDate(), 730 | this.getEndDate(), 731 | this.getTimelineWidth(), 732 | this.getTimelineSnap() 733 | ); 734 | if (durationChange === null) durationChange = this.getEndFromItem(item).diff(newEnd, 'minutes'); 735 | 736 | this.setEndToItem(item, newEnd); 737 | } 738 | 739 | // Check row height doesn't need changing 740 | let new_row_height = getMaxOverlappingItems( 741 | this.rowItemMap[rowNo], 742 | this.getStartFromItem, 743 | this.getEndFromItem 744 | ); 745 | if (new_row_height !== this.rowHeightCache[rowNo]) { 746 | this.rowHeightCache[rowNo] = new_row_height; 747 | } 748 | 749 | //Reset styles 750 | domItem.removeAttribute('isResizing'); 751 | domItem.removeAttribute('initialWidth'); 752 | domItem.style['z-index'] = 3; 753 | domItem.style.webkitTransform = domItem.style.transform = 'translate(0px, 0px)'; 754 | 755 | items.push(item); 756 | }); 757 | if (durationChange === null) durationChange = 0; 758 | const changes = {isStartTimeChange, timeDelta: -durationChange}; 759 | 760 | this.props.onInteraction && this.props.onInteraction(Timeline.changeTypes.resizeEnd, changes, items); 761 | 762 | e.target.setAttribute('delta-x', 0); 763 | this._grid.recomputeGridSize({rowIndex: minRowNo}); 764 | }); 765 | } 766 | 767 | if (canSelect) { 768 | this._selectRectangleInteractable 769 | .draggable({ 770 | enabled: true, 771 | ignoreFrom: '.item_draggable, .rct9k-group' 772 | }) 773 | .styleCursor(false) 774 | .on('dragstart', e => { 775 | const nearestRowObject = getNearestRowObject(e.clientX, e.clientY); 776 | 777 | // this._selectBox.start(e.clientX, e.clientY); 778 | // this._selectBox.start(e.clientX, topRowObj.style.top); 779 | this._selectBox.start(e.clientX, nearestRowObject.getBoundingClientRect().y); 780 | // const bottomRow = Number(getNearestRowNumber(left + width, top + height)); 781 | }) 782 | .on('dragmove', e => { 783 | const magicalConstant = 2; 784 | // @bendog: I added this magical constant to solve the issue of selection bleed, 785 | // I don't understand why it works, but if frequentist statisticians can use imaginary numbers, so can i. 786 | const {startX, startY} = this._selectBox; 787 | const startRowObject = getNearestRowObject(startX, startY); 788 | const {clientX, clientY} = e; 789 | const currentRowObject = getNearestRowObject(clientX, clientY); 790 | if (currentRowObject !== undefined && startRowObject !== undefined) { 791 | // only run if you can detect the top row 792 | const startRowNumber = getRowObjectRowNumber(startRowObject); 793 | const currentRowNumber = getRowObjectRowNumber(currentRowObject); 794 | // const numRows = 1 + Math.abs(startRowNumber - currentRowNumber); 795 | const rowMarginBorder = getVerticalMarginBorder(currentRowObject); 796 | if (startRowNumber <= currentRowNumber) { 797 | // select box for selection going down 798 | // get the first selected rows top 799 | const startTop = Math.ceil(startRowObject.getBoundingClientRect().top + rowMarginBorder); 800 | // get the currently selected rows bottom 801 | const currentBottom = Math.floor(getTrueBottom(currentRowObject) - magicalConstant - rowMarginBorder); 802 | this._selectBox.start(startX, startTop); 803 | this._selectBox.move(clientX, currentBottom); 804 | } else { 805 | // select box for selection going up 806 | // get the currently selected rows top 807 | const currentTop = Math.ceil(currentRowObject.getBoundingClientRect().top + rowMarginBorder); 808 | // get the first selected rows bottom 809 | const startBottom = Math.floor(getTrueBottom(startRowObject) - magicalConstant - rowMarginBorder * 2); 810 | // the bottom will bleed south unless you counter the margins and boreders from the above rows 811 | this._selectBox.start(startX, startBottom); 812 | this._selectBox.move(clientX, currentTop); 813 | } 814 | } 815 | }) 816 | .on('dragend', e => { 817 | let {top, left, width, height} = this._selectBox.end(); 818 | //Get the start and end row of the selection rectangle 819 | const topRowObject = getNearestRowObject(left, top); 820 | if (topRowObject !== undefined) { 821 | // only confirm the end of a drag if the selection box is valid 822 | const topRowNumber = Number(getNearestRowNumber(left, top)); 823 | const topRowLoc = topRowObject.getBoundingClientRect(); 824 | const rowMarginBorder = getVerticalMarginBorder(topRowObject); 825 | const bottomRow = Number( 826 | getNearestRowNumber( 827 | left + width, 828 | Math.floor(topRowLoc.top - rowMarginBorder) + Math.floor(height - rowMarginBorder) 829 | ) 830 | ); 831 | //Get the start and end time of the selection rectangle 832 | left = left - topRowLoc.left; 833 | let startOffset = width > 0 ? left : left + width; 834 | let endOffset = width > 0 ? left + width : left; 835 | const startTime = getTimeAtPixel( 836 | startOffset, 837 | this.getStartDate(), 838 | this.getEndDate(), 839 | this.getTimelineWidth(), 840 | this.getTimelineSnap() 841 | ); 842 | const endTime = getTimeAtPixel( 843 | endOffset, 844 | this.getStartDate(), 845 | this.getEndDate(), 846 | this.getTimelineWidth(), 847 | this.getTimelineSnap() 848 | ); 849 | //Get items in these ranges 850 | let selectedItems = []; 851 | for (let r = Math.min(topRowNumber, bottomRow); r <= Math.max(topRowNumber, bottomRow); r++) { 852 | selectedItems.push( 853 | ..._.filter(this.rowItemMap[r], i => { 854 | return this.getStartFromItem(i).isBefore(endTime) && this.getEndFromItem(i).isAfter(startTime); 855 | }) 856 | ); 857 | } 858 | this.props.onInteraction && this.props.onInteraction(Timeline.changeTypes.itemsSelected, selectedItems); 859 | } 860 | }); 861 | } 862 | } 863 | 864 | _handleItemRowEvent = (e, itemCallback, rowCallback) => { 865 | e.preventDefault(); 866 | // Skip click handler if selecting with selection box 867 | if (this.selecting) { 868 | return; 869 | } 870 | if (e.target.hasAttribute('data-item-index') || e.target.parentElement.hasAttribute('data-item-index')) { 871 | let itemKey = e.target.getAttribute('data-item-index') || e.target.parentElement.getAttribute('data-item-index'); 872 | itemCallback && itemCallback(e, Number(itemKey)); 873 | } else { 874 | let row = e.target.getAttribute('data-row-index'); 875 | let clickedTime = getTimeAtPixel( 876 | e.clientX - this.calculateLeftOffset(), 877 | this.getStartDate(), 878 | this.getEndDate(), 879 | this.getTimelineWidth() 880 | ); 881 | 882 | //const roundedStartMinutes = Math.round(clickedTime.minute() / this.props.snap) * this.props.snap; // I dont know what this does 883 | let snappedClickedTime = timeSnap(clickedTime, this.getTimelineSnap() * 60); 884 | rowCallback && rowCallback(e, row, clickedTime, snappedClickedTime); 885 | } 886 | }; 887 | 888 | /** 889 | * @param {number} width container width (in px) 890 | */ 891 | cellRenderer(width) { 892 | /** 893 | * @param {} columnIndex Always 1 894 | * @param {} key Unique key within array of cells 895 | * @param {} parent Reference to the parent Grid (instance) 896 | * @param {} rowIndex Vertical (row) index of cell 897 | * @param {} style Style object to be applied to cell (to position it); 898 | */ 899 | const {timelineMode, onItemHover, onItemLeave, rowLayers} = this.props; 900 | const canSelect = Timeline.isBitSet(Timeline.TIMELINE_MODES.SELECT, timelineMode); 901 | return ({columnIndex, key, parent, rowIndex, style}) => { 902 | // the items column is the last column in the grid; itemCol is the index of this column 903 | let itemCol = this.props.tableColumns && this.props.tableColumns.length > 0 ? this.props.tableColumns.length : 1; 904 | if (itemCol == columnIndex) { 905 | let itemsInRow = this.rowItemMap[rowIndex]; 906 | const layersInRow = rowLayers.filter(r => r.rowNumber === rowIndex); 907 | let rowHeight = this.props.itemHeight; 908 | if (this.rowHeightCache[rowIndex]) { 909 | rowHeight = rowHeight * this.rowHeightCache[rowIndex]; 910 | } 911 | return ( 912 |
this._handleItemRowEvent(e, Timeline.no_op, this.props.onRowClick)} 918 | onMouseDown={e => (this.selecting = false)} 919 | onMouseMove={e => (this.selecting = true)} 920 | onMouseOver={e => { 921 | this.selecting = false; 922 | return this._handleItemRowEvent(e, onItemHover, null); 923 | }} 924 | onMouseLeave={e => { 925 | this.selecting = false; 926 | return this._handleItemRowEvent(e, onItemLeave, null); 927 | }} 928 | onContextMenu={e => 929 | this._handleItemRowEvent(e, this.props.onItemContextClick, this.props.onRowContextClick) 930 | } 931 | onDoubleClick={e => this._handleItemRowEvent(e, this.props.onItemDoubleClick, this.props.onRowDoubleClick)}> 932 | {rowItemsRenderer( 933 | itemsInRow, 934 | this.getStartDate(), 935 | this.getEndDate(), 936 | width, 937 | this.props.itemHeight, 938 | this.props.itemRenderer, 939 | canSelect ? this.props.selectedItems : [], 940 | this.getStartFromItem, 941 | this.getEndFromItem 942 | )} 943 | {rowLayerRenderer( 944 | layersInRow, 945 | this.getStartDate(), 946 | this.getEndDate(), 947 | width, 948 | rowHeight, 949 | this.getStartFromRowLayer, 950 | this.getEndFromRowLayer 951 | )} 952 |
953 | ); 954 | } else { 955 | // Single column mode: the renderer of the cell is props.groupRenderer 956 | // with default labelProperty: SINGLE_COLUMN_LABEL_PROPERTY(title). 957 | // 958 | // Multiple columns mode: default renderer - props.groupRenderer with column.labelProperty; 959 | // custom renderer: column.cellRenderer. 960 | let labelProperty = ''; 961 | let ColumnRenderer = this.props.groupRenderer; 962 | if (this.props.tableColumns && this.props.tableColumns.length > 0) { 963 | const column = this.props.tableColumns[columnIndex]; 964 | if (column.cellRenderer) { 965 | ColumnRenderer = column.cellRenderer; 966 | } else { 967 | labelProperty = column.labelProperty; 968 | } 969 | } else { 970 | labelProperty = SINGLE_COLUMN_LABEL_PROPERTY; 971 | } 972 | let group = _.find(this.props.groups, g => g.id == rowIndex); 973 | return ( 974 |
975 | {React.isValidElement(ColumnRenderer) && ColumnRenderer} 976 | {!React.isValidElement(ColumnRenderer) && ( 977 | 978 | )} 979 |
980 | ); 981 | } 982 | }; 983 | } 984 | 985 | getCursor() { 986 | const {showCursorTime, cursorTimeFormat} = this.props; 987 | const {cursorTime} = this.state; 988 | return showCursorTime && cursorTime ? cursorTime.clone().format(cursorTimeFormat) : null; 989 | } 990 | 991 | /** 992 | * Helper for react virtuaized to get the row height given a row index 993 | */ 994 | rowHeight({index}) { 995 | let rh = this.rowHeightCache[index] ? this.rowHeightCache[index] : 1; 996 | return rh * this.props.itemHeight; 997 | } 998 | 999 | /** 1000 | * Set the grid ref. 1001 | * @param {Object} reactComponent Grid react element 1002 | */ 1003 | grid_ref_callback(reactComponent) { 1004 | this._grid = reactComponent; 1005 | this._gridDomNode = ReactDOM.findDOMNode(this._grid); 1006 | } 1007 | 1008 | /** 1009 | * Set the select box ref. 1010 | * @param {Object} reactComponent Selectbox react element 1011 | */ 1012 | select_ref_callback(reactComponent) { 1013 | this._selectBox = reactComponent; 1014 | } 1015 | 1016 | /** 1017 | * Event handler for onMouseMove. 1018 | * Only calls back if a new snap time is reached 1019 | */ 1020 | throttledMouseMoveFunc(e) { 1021 | const {componentId} = this.props; 1022 | const leftOffset = document.querySelector(`.rct9k-id-${componentId} .parent-div`).getBoundingClientRect().left; 1023 | const cursorSnappedTime = getTimeAtPixel( 1024 | e.clientX - this.calculateLeftOffset() - leftOffset, 1025 | this.getStartDate(), 1026 | this.getEndDate(), 1027 | this.getTimelineWidth(), 1028 | this.getTimelineSnap() 1029 | ); 1030 | if (!this.mouse_snapped_time || this.mouse_snapped_time.unix() !== cursorSnappedTime.unix()) { 1031 | if (cursorSnappedTime.isSameOrAfter(this.getStartDate())) { 1032 | this.mouse_snapped_time = cursorSnappedTime; 1033 | this.setState({cursorTime: this.mouse_snapped_time}); 1034 | this.props.onInteraction && 1035 | this.props.onInteraction( 1036 | Timeline.changeTypes.snappedMouseMove, 1037 | {snappedTime: this.mouse_snapped_time.clone()}, 1038 | null 1039 | ); 1040 | } 1041 | } 1042 | } 1043 | 1044 | mouseMoveFunc(e) { 1045 | e.persist(); 1046 | this.throttledMouseMoveFunc(e); 1047 | } 1048 | 1049 | /** 1050 | * Calculates left offset of the timeline (group lists). If props.tableColumns is defined, 1051 | * the left offset is the sum of the widths of all tableColumns; otherwise returns groupOffset. 1052 | * @returns left offset 1053 | */ 1054 | calculateLeftOffset() { 1055 | const {tableColumns, groupOffset} = this.props; 1056 | if (!tableColumns || tableColumns.length == 0) { 1057 | return groupOffset; 1058 | } 1059 | 1060 | let totalOffset = 0; 1061 | tableColumns.forEach(column => { 1062 | totalOffset += column.width ? column.width : groupOffset; 1063 | }); 1064 | return totalOffset; 1065 | } 1066 | 1067 | render() { 1068 | const { 1069 | onInteraction, 1070 | groupOffset, 1071 | showCursorTime, 1072 | timebarFormat, 1073 | componentId, 1074 | groupTitleRenderer, 1075 | shallowUpdateCheck, 1076 | forceRedrawFunc, 1077 | bottomResolution, 1078 | topResolution, 1079 | tableColumns 1080 | } = this.props; 1081 | let that = this; 1082 | 1083 | const divCssClass = `rct9k-timeline-div rct9k-id-${componentId}`; 1084 | let varTimebarProps = {}; 1085 | if (timebarFormat) varTimebarProps['timeFormats'] = timebarFormat; 1086 | if (bottomResolution) varTimebarProps['bottom_resolution'] = bottomResolution; 1087 | if (topResolution) varTimebarProps['top_resolution'] = topResolution; 1088 | 1089 | function getColumnWidth(column) { 1090 | return column.width ? column.width : groupOffset; 1091 | } 1092 | 1093 | function columnWidth(width) { 1094 | return ({index}) => { 1095 | // The width of the first column when tableColumns is not defined is groupOffset. 1096 | if (index == 0 && (!that.props.tableColumns || that.props.tableColumns.length == 0)) return groupOffset; 1097 | 1098 | // The width of the last column is width minus the left offset. 1099 | // The left offset is groupOffset when tableColumns is not defined or 1100 | // the sum of the widths of all tableColumns. 1101 | let leftOffset = groupOffset; 1102 | if (that.props.tableColumns && that.props.tableColumns.length > 0) { 1103 | if (index < that.props.tableColumns.length) { 1104 | return getColumnWidth(that.props.tableColumns[index]); 1105 | } else { 1106 | leftOffset = 0; 1107 | that.props.tableColumns.forEach(column => { 1108 | leftOffset += getColumnWidth(column); 1109 | }); 1110 | } 1111 | } 1112 | return width - leftOffset; 1113 | }; 1114 | } 1115 | 1116 | function calculateHeight(height) { 1117 | if (typeof window === 'undefined') { 1118 | return 0; 1119 | } 1120 | // when this function is called for the first time, the timebar is not yet rendered 1121 | let timebar = document.querySelector(`.rct9k-id-${componentId} .rct9k-timebar`); 1122 | if (!timebar) { 1123 | return 0; 1124 | } 1125 | // substract timebar height from total height 1126 | const timebarHeight = timebar.getBoundingClientRect().height; 1127 | return Math.max(height - timebarHeight, 0); 1128 | } 1129 | 1130 | // Markers (only current time marker atm) 1131 | const markers = []; 1132 | if (showCursorTime && this.mouse_snapped_time) { 1133 | const cursorPix = getPixelAtTime( 1134 | this.mouse_snapped_time, 1135 | this.getStartDate(), 1136 | this.getEndDate(), 1137 | this.getTimelineWidth() 1138 | ); 1139 | markers.push({ 1140 | left: cursorPix + this.calculateLeftOffset(), 1141 | key: 1 1142 | }); 1143 | } 1144 | return ( 1145 |
1146 | 1147 | {({height, width}) => ( 1148 |
1149 | 1150 | 1162 | {markers.map(m => ( 1163 | 1164 | ))} 1165 | 0 ? tableColumns.length : 1) + 1} 1172 | cellRenderer={this.cellRenderer(this.getTimelineWidth(width))} 1173 | grid_ref_callback={this.grid_ref_callback} 1174 | shallowUpdateCheck={shallowUpdateCheck} 1175 | forceRedrawFunc={forceRedrawFunc} 1176 | /> 1177 |
1178 | )} 1179 |
1180 |
1181 | ); 1182 | } 1183 | } 1184 | -------------------------------------------------------------------------------- /src/timeline.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import {shallow} from 'enzyme'; 4 | import {expect} from 'chai'; 5 | 6 | import setup from 'setupTests'; 7 | 8 | import Timeline from 'timeline'; 9 | 10 | // describe('', function() { 11 | // it('should have text', function() { 12 | // const wrapper = shallow(); 13 | // expect(wrapper.text()).to.equal('Hello'); 14 | // }); 15 | // }); 16 | -------------------------------------------------------------------------------- /src/utils/commonUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add int pixels to a css style (left or top generally) 3 | * @param {string} style Style string in css format 4 | * @param {number} diff The pixels to add/subtract 5 | * @returns {string} Style as string for css use 6 | */ 7 | export function sumStyle(style, diff) { 8 | return intToPix(pixToInt(style) + diff); 9 | } 10 | /** 11 | * Converts a pixel string to an int 12 | * @param {string} pix Pixel string 13 | * @return {number} Integer value of the pixel string 14 | */ 15 | export function pixToInt(pix) { 16 | return parseInt(pix.replace('px', '')); 17 | } 18 | /** 19 | * Convert integer to pixel string. 20 | * If not an integer the input is returned as is 21 | * @param {number} int Integer value 22 | * @returns {string} Pixel string 23 | */ 24 | export function intToPix(int) { 25 | if (int === Number(int)) return int + 'px'; 26 | return int; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/commonUtils.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import {intToPix, pixToInt} from './commonUtils'; 4 | 5 | describe('Common Utils', function() { 6 | describe('intToPix', function() { 7 | it('should convert an int to a pixel string', function() { 8 | expect(intToPix(1)).to.equal('1px'); 9 | }); 10 | it('should leave already converted strings as is', function() { 11 | expect(intToPix('1px')).to.equal('1px'); 12 | }); 13 | }); 14 | describe('pixToInt', function() { 15 | it('should convert a string to an int', function() { 16 | expect(pixToInt('1px')).to.equal(1); 17 | }); 18 | it('should convert a string to an int (2)', function() { 19 | expect(pixToInt('1 px')).to.equal(1); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/utils/itemUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import _ from 'lodash'; 5 | import moment from 'moment'; 6 | 7 | /** 8 | * Render all items in a row 9 | * @external {moment} http://momentjs.com/ 10 | * @param {Object[]} items List of items to render for this row 11 | * @param {moment} vis_start The visible start of the timeline 12 | * @param {moment} vis_end The visible end of the timeline 13 | * @param {number} total_width pixel width of the timeline 14 | * @param {number} itemHeight The height of the item in px 15 | * @param {function} itemRenderer The renderer of the item 16 | * @param {Object[]} selectedItems 17 | * @param {function} getStartFromItem Function that returns the start of an item 18 | * @param {function} getEndFromItem Function that returns the end of an item 19 | */ 20 | export function rowItemsRenderer( 21 | items, 22 | vis_start, 23 | vis_end, 24 | total_width, 25 | itemHeight, 26 | itemRenderer, 27 | selectedItems = [], 28 | getStartFromItem, 29 | getEndFromItem 30 | ) { 31 | const start_end_ms = vis_end.diff(vis_start, 'milliseconds'); 32 | const pixels_per_ms = total_width / start_end_ms; 33 | let filtered_items = _.sortBy( 34 | _.filter(items, i => { 35 | // if end not before window && start not after window 36 | return !getEndFromItem(i).isBefore(vis_start) && !getStartFromItem(i).isAfter(vis_end); 37 | }), 38 | i => -getStartFromItem(i).unix() 39 | ); // sorted in reverse order as we iterate over the array backwards 40 | let displayItems = []; 41 | let rowOffset = 0; 42 | while (filtered_items.length > 0) { 43 | let lastEnd = null; 44 | for (let i = filtered_items.length - 1; i >= 0; i--) { 45 | if (lastEnd === null || getStartFromItem(filtered_items[i]) >= lastEnd) { 46 | let item = _.clone(filtered_items[i]); 47 | item.rowOffset = rowOffset; 48 | displayItems.push(item); 49 | filtered_items.splice(i, 1); 50 | lastEnd = getEndFromItem(item); 51 | } 52 | } 53 | rowOffset++; 54 | } 55 | return _.map(displayItems, i => { 56 | const {color} = i; 57 | const Comp = itemRenderer; 58 | let top = itemHeight * i['rowOffset']; 59 | let item_offset_mins = getStartFromItem(i).diff(vis_start, 'milliseconds'); 60 | let item_duration_mins = getEndFromItem(i).diff(getStartFromItem(i), 'milliseconds'); 61 | let left = Math.round(item_offset_mins * pixels_per_ms); 62 | let width = Math.round(item_duration_mins * pixels_per_ms); 63 | let compClassnames = 'rct9k-items-inner'; 64 | let outerClassnames = 'rct9k-items-outer item_draggable'; 65 | let style = {backgroundColor: color}; 66 | let isSelected = selectedItems.indexOf(Number(i.key)) > -1; 67 | 68 | if (isSelected) { 69 | compClassnames += ' rct9k-items-selected'; 70 | outerClassnames += ' rct9k-items-outer-selected'; 71 | style = {}; 72 | } 73 | 74 | return ( 75 | 80 | 81 | 82 | ); 83 | }); 84 | } 85 | 86 | /** 87 | * Render row layers 88 | * @param {Object[]} layers List of layers to render for this row 89 | * @param {moment} vis_start The visible start of the timeline 90 | * @param {moment} vis_end The visible end of the timeline 91 | * @param {number} total_width pixel width of the timeline 92 | * @param {number} itemHeight The layer height in px 93 | * @param {function} getStartFromRowLayer Function that returns the start of a row layer 94 | * @param {function} getEndFromRowLayer Function that returns the end of a row layer 95 | */ 96 | export function rowLayerRenderer( 97 | layers, 98 | vis_start, 99 | vis_end, 100 | total_width, 101 | itemHeight, 102 | getStartFromRowLayer, 103 | getEndFromRowLayer 104 | ) { 105 | const start_end_ms = vis_end.diff(vis_start, 'milliseconds'); 106 | const pixels_per_ms = total_width / start_end_ms; 107 | let filtered_items = _.sortBy( 108 | _.filter(layers, i => { 109 | return !getEndFromRowLayer(i).isBefore(vis_start) && !getStartFromRowLayer(i).isAfter(vis_end); 110 | }), 111 | i => -getStartFromRowLayer(i).unix() 112 | ); // sorted in reverse order as we iterate over the array backwards 113 | let displayItems = []; 114 | let rowOffset = 0; 115 | while (filtered_items.length > 0) { 116 | let lastEnd = null; 117 | for (let i = filtered_items.length - 1; i >= 0; i--) { 118 | if (lastEnd === null || getStartFromRowLayer(filtered_items[i]) >= lastEnd) { 119 | let item = _.clone(filtered_items[i]); 120 | item.rowOffset = rowOffset; 121 | displayItems.push(item); 122 | filtered_items.splice(i, 1); 123 | lastEnd = getEndFromRowLayer(item); 124 | } 125 | } 126 | rowOffset++; 127 | } 128 | return _.map(displayItems, i => { 129 | const {style, rowNumber} = i; 130 | let top = itemHeight * i['rowOffset']; 131 | let item_offset_mins = getStartFromRowLayer(i).diff(vis_start, 'milliseconds'); 132 | let item_duration_mins = getEndFromRowLayer(i).diff(getStartFromRowLayer(i), 'milliseconds'); 133 | let left = Math.round(item_offset_mins * pixels_per_ms); 134 | let width = Math.round(item_duration_mins * pixels_per_ms); 135 | let height = itemHeight - (rowNumber === 0 ? 2 : 1); // for border 136 | let outerClassnames = 'rct9k-row-layer'; 137 | 138 | return ( 139 |
145 | ); 146 | }); 147 | } 148 | 149 | /** 150 | * Gets the row object for a given x and y pixel location 151 | * @param {number} x The x coordinate of the pixel location 152 | * @param {number} y The y coordinate of the pixel location 153 | * @param {Object} topDiv Div to search under 154 | * @returns {Object} The row object at that coordinate 155 | */ 156 | export function getNearestRowObject(x, y, topDiv = document) { 157 | let elementsAtPixel = document.elementsFromPoint(x, y); 158 | return _.find(elementsAtPixel, e => { 159 | const inDiv = topDiv.contains(e); 160 | return inDiv && e.hasAttribute('data-row-index'); 161 | }); 162 | } 163 | 164 | /** 165 | * Gets the row number for a given row object 166 | * @param {Object} elem The row object 167 | * @returns {number} The row number 168 | */ 169 | export function getRowObjectRowNumber(elem) { 170 | return Number(elem ? elem.getAttribute('data-row-index') : 0); 171 | } 172 | 173 | /** 174 | * Gets the vertical margins and borders given an object 175 | * @param {Object} elem The row object 176 | * @returns {number} the pixel position of the bottom of the element 177 | */ 178 | export function getVerticalMarginBorder(elem) { 179 | const computedStyles = window.getComputedStyle(elem); 180 | // top margin plus bottom margin halved 181 | const rowMargins = 182 | (Math.ceil(parseFloat(computedStyles['marginTop']) + parseFloat(computedStyles['marginBottom'])) || 1) / 2; 183 | // half the size of the border seems important 184 | const rowBorders = 185 | (Math.ceil(parseFloat(computedStyles['borderTopWidth']) + parseFloat(computedStyles['borderBottomWidth'])) || 1) / 186 | 2; 187 | return Number(rowMargins + rowBorders); 188 | } 189 | 190 | /** 191 | * Gets the true bottom location given an object 192 | * @param {Object} elem an element 193 | * @returns {number} the pixel position of the bottom of the element 194 | */ 195 | export function getTrueBottom(elem) { 196 | /* 197 | @bendog: leaving this here as a helper, if there's ever a bug around inner items size 198 | // get object shape 199 | const rects = elem.getClientRects(); 200 | const bottom = Math.max(Object.values(rects).map(o => o.bottom), 0); 201 | */ 202 | // calculate the true bottom 203 | const bound = elem.getBoundingClientRect(); 204 | const bottom = Math.floor(bound.top + bound.height); 205 | return Number(bottom); 206 | } 207 | 208 | /** 209 | * Gets the row number for a given x and y pixel location 210 | * @param {number} x The x coordinate of the pixel location 211 | * @param {number} y The y coordinate of the pixel location 212 | * @param {Object} topDiv Div to search under 213 | * @returns {number} The row number 214 | */ 215 | export function getNearestRowNumber(x, y, topDiv = document) { 216 | let elementsAtPixel = document.elementsFromPoint(x, y); 217 | let targetRow = _.find(elementsAtPixel, e => { 218 | const inDiv = topDiv.contains(e); 219 | return inDiv && e.hasAttribute('data-row-index'); 220 | }); 221 | return targetRow ? targetRow.getAttribute('data-row-index') : 0; 222 | } 223 | 224 | /** 225 | * Use to find the height of a row, given a set of items 226 | * @param {Object[]} items List of items 227 | * @param {function} getStartFromItem Function that returns the start of an item. 228 | * @param {function} getEndFromItem Function that returns the end of an item. 229 | * @param {boolean} useMoment This parameter is necessary because this method is also called when 230 | * the component receives new props. 231 | * @returns {number} Max row height 232 | */ 233 | export function getMaxOverlappingItems(items, getStartFromItem, getEndFromItem, useMoment) { 234 | let max = 0; 235 | let sorted_items = _.sortBy(items, i => -getStartFromItem(i, useMoment).unix()); 236 | while (sorted_items.length > 0) { 237 | let lastEnd = null; 238 | for (let i = sorted_items.length - 1; i >= 0; i--) { 239 | if (lastEnd === null || getStartFromItem(sorted_items[i], useMoment) >= lastEnd) { 240 | lastEnd = getEndFromItem(sorted_items[i], useMoment); 241 | sorted_items.splice(i, 1); 242 | } 243 | } 244 | max++; 245 | } 246 | return Math.max(max, 1); 247 | } 248 | -------------------------------------------------------------------------------- /src/utils/itemUtils.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import _ from 'lodash'; 4 | import moment from 'moment'; 5 | import {getMaxOverlappingItems} from './itemUtils'; 6 | import {convertDateToMoment} from './timeUtils'; 7 | 8 | function getStartFromItem(item) { 9 | return convertDateToMoment(item.start, true); 10 | } 11 | 12 | function getEndFromItem(item) { 13 | return convertDateToMoment(item.end, true); 14 | } 15 | 16 | // 17 | // |--1--| 18 | // |--2--| 19 | // |--3--| 20 | // |--4--| 21 | // |--5--| 22 | const allTestItems = [ 23 | { 24 | key: '1', 25 | title: '1', 26 | color: 'blue', 27 | row: 1, 28 | start: moment('2000-01-01'), 29 | end: moment('2000-01-01').add(1, 'days') 30 | }, 31 | { 32 | key: '2', 33 | title: '2', 34 | color: 'blue', 35 | row: 1, 36 | start: moment('2000-01-03'), 37 | end: moment('2000-01-03') 38 | .startOf('day') 39 | .add(1, 'days') 40 | }, 41 | { 42 | key: '3', 43 | title: '3', 44 | color: 'blue', 45 | row: 1, 46 | start: moment('2000-01-01').add(1, 'hours'), 47 | end: moment('2000-01-01') 48 | .add(1, 'hours') 49 | .add(1, 'days') 50 | }, 51 | { 52 | key: '4', 53 | title: '4', 54 | color: 'blue', 55 | row: 1, 56 | start: moment('2000-01-01').add(1, 'days'), 57 | end: moment('2000-01-01') 58 | .add(1, 'hours') 59 | .add(1, 'days') 60 | }, 61 | { 62 | key: '5', 63 | title: '5', 64 | color: 'blue', 65 | row: 1, 66 | start: moment('2000-01-01').add(2, 'hours'), 67 | end: moment('2000-01-01') 68 | .add(2, 'hours') 69 | .add(1, 'days') 70 | } 71 | ]; 72 | 73 | describe('Item Utils', function() { 74 | describe('getMaxOverlappingItems', function() { 75 | it('should return a default of 1', function() { 76 | const result = getMaxOverlappingItems([], getStartFromItem, getEndFromItem); 77 | expect(result).to.equal(1); 78 | }); 79 | // Diagram 80 | // |-----| |----| 81 | it('should return 1 when no overlapping items', function() { 82 | let testInstanceIDs = ['1', '2']; 83 | let items = _.filter(allTestItems, i => { 84 | return _.includes(testInstanceIDs, i.key); 85 | }); 86 | const result = getMaxOverlappingItems(items, getStartFromItem, getEndFromItem); 87 | expect(result).to.equal(1); 88 | }); 89 | // Diagram 90 | // |-----| 91 | // |------| 92 | it('should return 2 when 2 items overlap', () => { 93 | let testInstanceIDs = ['1', '3']; 94 | let items = _.filter(allTestItems, i => { 95 | return _.includes(testInstanceIDs, i.key); 96 | }); 97 | const result = getMaxOverlappingItems(items, getStartFromItem, getEndFromItem); 98 | expect(result).to.equal(2); 99 | }); 100 | // Diagram 101 | // |-----|-----| 102 | it('should return 1 when 2 items touch', () => { 103 | let testInstanceIDs = ['1', '4']; 104 | let items = _.filter(allTestItems, i => { 105 | return _.includes(testInstanceIDs, i.key); 106 | }); 107 | const result = getMaxOverlappingItems(items, getStartFromItem, getEndFromItem); 108 | expect(result).to.equal(1); 109 | }); 110 | // Diagram 111 | // |-----| 112 | // |------| 113 | // |------| 114 | it('should return 3 when 3 items overlap', () => { 115 | let testInstanceIDs = ['1', '3', '5']; 116 | let items = _.filter(allTestItems, i => { 117 | return _.includes(testInstanceIDs, i.key); 118 | }); 119 | const result = getMaxOverlappingItems(items, getStartFromItem, getEndFromItem); 120 | expect(result).to.equal(3); 121 | }); 122 | // Diagram 123 | // |-----| |------| 124 | // |------| 125 | it('should return 2 when 2 of 3 items overlap', () => { 126 | let testInstanceIDs = ['2', '3', '4']; 127 | let items = _.filter(allTestItems, i => { 128 | return _.includes(testInstanceIDs, i.key); 129 | }); 130 | const result = getMaxOverlappingItems(items, getStartFromItem, getEndFromItem); 131 | expect(result).to.equal(2); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/utils/timeUtils.js: -------------------------------------------------------------------------------- 1 | // Time utilities 2 | 3 | import moment from 'moment'; 4 | 5 | /** 6 | * Snaps a moment object to the given resolution 7 | * @param {moment} time The moment to snap 8 | * @param {number} snapMilliseconds The snap time in milliseconds 9 | * @returns {moment} Snapped moment 10 | */ 11 | export function timeSnap(time, snapMilliseconds) { 12 | if (snapMilliseconds === 0) { 13 | const newTime = time.clone(); 14 | newTime.set('millisecond', 0); 15 | return newTime; 16 | } 17 | const newUnix = Math.round((time.unix() * 1000) / snapMilliseconds) * snapMilliseconds; 18 | return moment(newUnix); 19 | } 20 | 21 | /** 22 | * Get the pixels per millisecond 23 | * @param {moment} vis_start The moment specifying the start of the visible timeline range 24 | * @param {moment} vis_end The moment specifying the end of the visible timeline range 25 | * @param {number} total_width The width of the timeline in pixels 26 | * @returns {float} The pixels per millisecond 27 | */ 28 | export function pixelsPerMillisecond(vis_start, vis_end, total_width) { 29 | const start_end_ms = vis_end.diff(vis_start, 'milliseconds'); 30 | return total_width / start_end_ms; 31 | } 32 | 33 | /** 34 | * 35 | * @param {number} delta the delta distance in pixels 36 | * @param {moment} vis_start the visible start of the timeline 37 | * @param {moment} vis_end the visible end of the timeline 38 | * @param {number} total_width the pixel width of the timeline 39 | * @param {number} snapMilliseconds the number of milliseconds to snap to 40 | */ 41 | export function getSnapPixelFromDelta(delta, vis_start, vis_end, total_width, snapMilliseconds = 0) { 42 | const pixelsPerSnapSegment = pixelsPerMillisecond(vis_start, vis_end, total_width) * snapMilliseconds; 43 | return Math.round(delta / pixelsPerSnapSegment) * pixelsPerSnapSegment; 44 | } 45 | 46 | /** 47 | * Get the time at a pixel location 48 | * @param {number} pixel_location the pixel location (generally from left css style) 49 | * @param {moment} vis_start The visible start of the timeline 50 | * @param {moment} vis_end The visible end of the timeline 51 | * @param {number} total_width The pixel width of the timeline (row portion) 52 | * @param {number} snapMilliseconds The snap resolution (in ms) 53 | * @returns {moment} Moment object 54 | */ 55 | export function getTimeAtPixel(pixel_location, vis_start, vis_end, total_width, snapMilliseconds = 0) { 56 | let min_offset = pixel_location / pixelsPerMillisecond(vis_start, vis_end, total_width); 57 | let timeAtPix = vis_start.clone().add(min_offset, 'milliseconds'); 58 | if (snapMilliseconds !== 0) timeAtPix = timeSnap(timeAtPix, snapMilliseconds); 59 | return timeAtPix; 60 | } 61 | /** 62 | * Get the pixel location at a specific time 63 | * @param {objects} time The time (moment) object 64 | * @param {moment} vis_start The visible start of the timeline 65 | * @param {moment} vis_end The visible end of the timeline 66 | * @param {number} total_width The width in pixels of the grid 67 | * @returns {number} The pixel offset 68 | */ 69 | export function getPixelAtTime(time, vis_start, vis_end, total_width) { 70 | const min_from_start = time.diff(vis_start, 'milliseconds'); 71 | return min_from_start * pixelsPerMillisecond(vis_start, vis_end, total_width); 72 | } 73 | /** 74 | * Returns the duration from the {@link vis_start} 75 | * @param {number} pixels 76 | * @param {moment} vis_start The visible start of the timeline 77 | * @param {moment} vis_end The visible end of the timeline 78 | * @param {number} total_width The width in pixels of the grid 79 | * @returns {moment} Moment duration 80 | */ 81 | export function getDurationFromPixels(pixels, vis_start, vis_end, total_width) { 82 | const start_end_ms = vis_end.diff(vis_start, 'milliseconds'); 83 | if (start_end_ms === 0) return moment.duration(0, 'milliseconds'); 84 | const pixels_per_ms = total_width / start_end_ms; 85 | let millis = pixels / pixels_per_ms; 86 | return moment.duration(millis, 'milliseconds'); 87 | } 88 | 89 | /** 90 | * If useMoment is true returns the date, otherwise converts date to moment. 91 | * @param {moment|number} date 92 | * @param {boolean} useMoment 93 | * @returns moment 94 | */ 95 | export function convertDateToMoment(date, useMoment) { 96 | if (useMoment) { 97 | return date; 98 | } 99 | return moment(date); 100 | } 101 | 102 | /** 103 | * If useMoment is true returns dateAsMoment, otherwise it converts dateAsMoment to milliseconds. 104 | * @param {moment} dateAsMoment moment to be converted 105 | * @param {boolean} useMoment if true return dateAsMoment, otherwise return millis 106 | * @returns a moment object or date in milliseconds 107 | */ 108 | export function convertMomentToDateType(dateAsMoment, useMoment) { 109 | if (useMoment) { 110 | return dateAsMoment; 111 | } 112 | return dateAsMoment.valueOf(); 113 | } 114 | -------------------------------------------------------------------------------- /src/utils/timeUtils.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import moment from 'moment'; 4 | import { 5 | timeSnap, 6 | getTimeAtPixel, 7 | getPixelAtTime, 8 | getDurationFromPixels, 9 | getSnapPixelFromDelta, 10 | convertDateToMoment, 11 | convertMomentToDateType 12 | } from './timeUtils'; 13 | 14 | describe('Time Utils', function() { 15 | describe('timeSnap', function() { 16 | it('should round up to the last sec', function() { 17 | const testTime = moment('2000-01-01 10:00:00.872 Z', 'YYYY-MM-DD H:m:s.SSS Z'); 18 | const expectedTime = moment('2000-01-01 10:00:00.000 Z', 'YYYY-MM-DD H:m:s.SSS Z'); 19 | const snap = 1000; 20 | const actualTime = timeSnap(testTime, snap); 21 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 22 | }); 23 | it('should round down to the last sec', function() { 24 | const testTime = moment('2000-01-01 10:00:00.272 Z', 'YYYY-MM-DD H:m:s.SSS Z'); 25 | const expectedTime = moment('2000-01-01 10:00:00.000 Z', 'YYYY-MM-DD H:m:s.SSS Z'); 26 | const snap = 1000; 27 | const actualTime = timeSnap(testTime, snap); 28 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 29 | }); 30 | it('should round up to the nearest min', function() { 31 | const testTime = moment('2000-01-01 9:59:50 Z', 'YYYY-MM-DD H:m:s Z'); 32 | const expectedTime = moment('2000-01-01 10:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 33 | const snap = 60 * 1000; 34 | const actualTime = timeSnap(testTime, snap); 35 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 36 | }); 37 | it('should round down to the nearest min', function() { 38 | const testTime = moment('2000-01-01 10:00:20 Z', 'YYYY-MM-DD H:m:s Z'); 39 | const expectedTime = moment('2000-01-01 10:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 40 | const snap = 60 * 1000; 41 | const actualTime = timeSnap(testTime, snap); 42 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 43 | }); 44 | it('should round up when at 30s (nearest min)', function() { 45 | const testTime = moment('2000-01-01 10:00:30 Z', 'YYYY-MM-DD H:m:s Z'); 46 | const expectedTime = moment('2000-01-01 10:01:00 Z', 'YYYY-MM-DD H:m:s Z'); 47 | const snap = 60 * 1000; 48 | const actualTime = timeSnap(testTime, snap); 49 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 50 | }); 51 | it('should round to nearest hour', function() { 52 | const testTime = moment('2000-01-01 10:12:30 Z', 'YYYY-MM-DD H:m:s Z'); 53 | const expectedTime = moment('2000-01-01 10:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 54 | const snap = 60 * 60 * 1000; 55 | const actualTime = timeSnap(testTime, snap); 56 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 57 | }); 58 | it('should round to nearest hour over mid-night', function() { 59 | const testTime = moment('2000-01-01 23:44:40 Z', 'YYYY-MM-DD H:m:s Z'); 60 | const expectedTime = moment('2000-01-02 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 61 | const snap = 60 * 60 * 1000; 62 | const actualTime = timeSnap(testTime, snap); 63 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 64 | }); 65 | it('should round up to nearest day', function() { 66 | const testTime = moment('2000-01-01 12:44:40 Z', 'YYYY-MM-DD H:m:s Z'); 67 | const expectedTime = moment('2000-01-02 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 68 | const snap = 60 * 60 * 24 * 1000; 69 | const actualTime = timeSnap(testTime, snap); 70 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 71 | }); 72 | it('should round down to nearest day', function() { 73 | const testTime = moment('2000-01-01 11:44:40 Z', 'YYYY-MM-DD H:m:s Z'); 74 | const expectedTime = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 75 | const snap = 60 * 60 * 24 * 1000; 76 | const actualTime = timeSnap(testTime, snap); 77 | expect(actualTime.unix()).to.equal(expectedTime.unix()); 78 | }); 79 | }); 80 | describe('getTimeAtPixel', function() { 81 | it('should return start time for 0', function() { 82 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 83 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 84 | const timelineWidth = 2000; //2000 px 85 | const pixelOffset = 0; 86 | let time = getTimeAtPixel(pixelOffset, visStart, visEnd, timelineWidth); 87 | expect(time.unix()).to.equal(visStart.unix()); 88 | }); 89 | it('should return before start for -ve pixels', function() { 90 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 91 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 92 | const timelineWidth = 2000; //2000 px 93 | const pixelOffset = -40; 94 | let time = getTimeAtPixel(pixelOffset, visStart, visEnd, timelineWidth); 95 | expect(time.unix()).to.lt(visStart.unix()); 96 | }); 97 | it('should return end time for width pixels', function() { 98 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 99 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 100 | const timelineWidth = 2000; //2000 px 101 | const pixelOffset = 2000; 102 | let time = getTimeAtPixel(pixelOffset, visStart, visEnd, timelineWidth); 103 | expect(time.unix()).to.equal(visEnd.unix()); 104 | }); 105 | it('should return higher than width for over width pixels', function() { 106 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 107 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 108 | const timelineWidth = 2000; //2000 px 109 | const pixelOffset = 2400; 110 | let time = getTimeAtPixel(pixelOffset, visStart, visEnd, timelineWidth); 111 | expect(time.unix()).to.gt(visEnd.unix()); 112 | }); 113 | it('should return correct fraction of time for given pixel location', function() { 114 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 115 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 116 | const timelineWidth = 2100; //2000 px 117 | const pixelOffset = 300; 118 | const expectedTime = visStart.clone().add((pixelOffset / timelineWidth) * 7, 'days'); 119 | let time = getTimeAtPixel(pixelOffset, visStart, visEnd, timelineWidth); 120 | expect(time.unix()).to.equal(expectedTime.unix()); 121 | }); 122 | }); 123 | describe('getPixelAtTime', function() { 124 | it('should return 0 for start time', function() { 125 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 126 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 127 | const timelineWidth = 2000; //2000 px 128 | const testTime = visStart.clone(); 129 | let pixels = getPixelAtTime(testTime, visStart, visEnd, timelineWidth); 130 | expect(pixels).to.equal(0); 131 | }); 132 | it('should return -ve for before start time', function() { 133 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 134 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 135 | const timelineWidth = 2000; //2000 px 136 | const testTime = moment('1999-12-30 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 137 | let pixels = getPixelAtTime(testTime, visStart, visEnd, timelineWidth); 138 | expect(pixels).to.lt(0); 139 | }); 140 | it('should return width for end time', function() { 141 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 142 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 143 | const timelineWidth = 2000; //2000 px 144 | const testTime = visEnd.clone(); 145 | let pixels = getPixelAtTime(testTime, visStart, visEnd, timelineWidth); 146 | expect(pixels).to.equal(timelineWidth); 147 | }); 148 | it('should return greater than width for after end time', function() { 149 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 150 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 151 | const timelineWidth = 2000; //2000 px 152 | const testTime = moment('2000-01-09 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 153 | let pixels = getPixelAtTime(testTime, visStart, visEnd, timelineWidth); 154 | expect(pixels).to.gt(timelineWidth); 155 | }); 156 | it('should return correct pixels for given fraction of time', function() { 157 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 158 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 159 | const timelineWidth = 2000; //2000 px 160 | const testTime = moment('2000-01-04 12:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 161 | const expectedPixels = timelineWidth * (3.5 / 7); 162 | let pixels = getPixelAtTime(testTime, visStart, visEnd, timelineWidth); 163 | expect(pixels).to.equal(expectedPixels); 164 | }); 165 | }); 166 | describe('getDurationFromPixels', function() { 167 | it('should return 0 for 0 pixels', function() { 168 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 169 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 170 | const timelineWidth = 2000; //2000 px 171 | const pixelOffset = 0; 172 | let duration = getDurationFromPixels(pixelOffset, visStart, visEnd, timelineWidth); 173 | expect(duration.asSeconds()).to.equal(0); 174 | }); 175 | it('should return negative duration for -ve pixels', function() { 176 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 177 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 178 | const timelineWidth = 2000; //2000 px 179 | const pixelOffset = -40; 180 | let duration = getDurationFromPixels(pixelOffset, visStart, visEnd, timelineWidth); 181 | expect(duration.asSeconds()).to.lt(0); 182 | }); 183 | it('should return (visible end - visible start) for width pixels', function() { 184 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 185 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 186 | const timelineWidth = 2000; //2000 px 187 | const pixelOffset = 2000; 188 | const expectedDuration = visEnd.diff(visStart, 'seconds'); 189 | let duration = getDurationFromPixels(pixelOffset, visStart, visEnd, timelineWidth); 190 | expect(duration.asSeconds()).to.equal(expectedDuration); 191 | }); 192 | it('should return higher than (visible end - visible start) for over width pixels', function() { 193 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 194 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 195 | const timelineWidth = 2000; //2000 px 196 | const pixelOffset = 2400; 197 | let duration = getDurationFromPixels(pixelOffset, visStart, visEnd, timelineWidth); 198 | expect(duration.asSeconds()).to.gt(moment.duration(7, 'days').asSeconds()); 199 | }); 200 | it('should return correct fraction of duration for given pixel location', function() { 201 | const visStart = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 202 | const visEnd = moment('2000-01-08 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); // 7 days 203 | const timelineWidth = 2100; //2000 px 204 | const pixelOffset = 300; 205 | const expectedDuration = (pixelOffset / timelineWidth) * 7 * 24 * 60 * 60; 206 | let duration = getDurationFromPixels(pixelOffset, visStart, visEnd, timelineWidth); 207 | expect(duration.asSeconds()).to.equal(expectedDuration); 208 | }); 209 | }), 210 | describe('convertDateToMoment', function() { 211 | it('should return the received date(moment) when useMoment is true', function() { 212 | const date = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 213 | const useMoment = true; 214 | let expectedMoment = date; 215 | let actualMoment = convertDateToMoment(date, useMoment); 216 | expect(actualMoment).to.deep.equal(expectedMoment); 217 | }); 218 | it('should convert date to moment when useMoment is false', function() { 219 | const date = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z').valueOf(); 220 | const useMoment = false; 221 | let expectedMoment = moment(date); 222 | let actualMoment = convertDateToMoment(date, useMoment); 223 | expect(actualMoment).to.deep.equal(expectedMoment); 224 | }); 225 | }), 226 | describe('convertMomentToDateType', function() { 227 | it('should return moment when useMoment is true', function() { 228 | const date = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 229 | const useMoment = true; 230 | let expectedMoment = date; 231 | let actualMoment = convertMomentToDateType(date, useMoment); 232 | expect(actualMoment).to.deep.equal(expectedMoment); 233 | }); 234 | it('should return date in millis when useMoment is false', function() { 235 | const date = moment('2000-01-01 00:00:00 Z', 'YYYY-MM-DD H:m:s Z'); 236 | const useMoment = false; 237 | let expectedDate = 946684800000; 238 | let actualDate = convertMomentToDateType(date, useMoment); 239 | expect(actualDate).to.deep.equal(expectedDate); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | output: { 6 | filename: '[name].bundle.js', 7 | path: path.resolve(__dirname, 'dist') 8 | }, 9 | plugins: [], 10 | module: { 11 | rules: [ 12 | { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }, 13 | {test: /\.css$/, 14 | use: [ 15 | { loader: "style-loader" }, 16 | { loader: "css-loader" } 17 | ]} 18 | ] 19 | }, 20 | resolve: { 21 | modules: [ 22 | path.resolve(__dirname, './node_modules'), 23 | path.resolve(__dirname, './src') 24 | ], 25 | }, 26 | stats: { 27 | colors: true 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /webpack.demo.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const { merge } = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | module.exports = merge(common, { 8 | entry: './src/demo_index.js', 9 | mode: 'production', 10 | devtool: 'source-map', 11 | plugins: [ 12 | new HtmlWebpackPlugin({ 13 | title: 'React Timeline 9000', 14 | template: 'src/demo.html', 15 | chunksSortMode: 'auto', 16 | inject: 'body', 17 | minify: { 18 | removeComments: false, 19 | collapseWhitespace: false 20 | } 21 | }), 22 | new webpack.DefinePlugin({ 23 | 'process.env.NODE_ENV': JSON.stringify('production') 24 | }) 25 | ] 26 | }); 27 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | module.exports = merge(common, { 7 | entry: './src/demo_index.js', 8 | mode: 'development', 9 | devtool: 'inline-source-map', 10 | plugins: [ 11 | new HtmlWebpackPlugin({ 12 | title: 'React Timeline 9000', 13 | template: 'src/demo.html', 14 | chunksSortMode: 'auto', 15 | inject: 'body', 16 | minify: { 17 | removeComments: false, 18 | collapseWhitespace: false 19 | } 20 | }) 21 | ], 22 | devServer: { 23 | static: './dist' 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const {merge} = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | 5 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); 6 | 7 | module.exports = merge(common, { 8 | mode: 'production', 9 | devtool: 'source-map', 10 | plugins: [ 11 | new UglifyJSPlugin({ 12 | sourceMap: true 13 | }), 14 | new webpack.DefinePlugin({ 15 | 'process.env.NODE_ENV': JSON.stringify('production') 16 | }) 17 | ] 18 | }); 19 | --------------------------------------------------------------------------------