├── .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 | [](https://travis-ci.org/BHP-DevHub/react-timeline-9000)
5 | [](https://www.codefactor.io/repository/github/bhp-devhub/react-timeline-9000)
6 | [](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 |
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 | this.reRender()}>
362 | Regenerate
363 |
364 |
365 |
366 | Zoom in
367 |
368 |
369 | Zoom out
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 |
--------------------------------------------------------------------------------