├── .babelrc ├── .github └── ISSUE_TEMPLATE │ ├── Bug_report.md │ ├── Custom.md │ └── Feature_request.md ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── _config.yml ├── docs └── index.html ├── images └── heatmap.jpg ├── package.json ├── src ├── dateHandlers.js ├── getDifference.js ├── heatMapScale.js ├── index.jsx ├── monthToString.js └── style.css └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react" 5 | ] 6 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | webpack.local.config.js 2 | webpack.production.config.js 3 | .eslintrc 4 | .gitignore -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at github@williamfretwell.co.uk. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 willfretwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # heatmap-calendar-react 2 | An interactive calendar with heatmap visualization. Based on GitHub's commit graph. 3 | 4 | The user can interact with the calendar using hover, click, click & drag, month selection and weekday selection. As well as navigate through the calendar with their selection using arrow keys: left, right, up, down, esc to cancel. Once the user has selected a date (or multiple dates) they can use the shift key to expand their selection. E.g. shift + right arrow key to expand right. 5 | 6 | [View demo here](https://1qrrl2z9r3.codesandbox.io/) 7 | 8 | [![npm version](https://badge.fury.io/js/heatmap-calendar-react.svg)](https://badge.fury.io/js/heatmap-calendar-react) 9 | 10 | ![heatmap calendar](https://github.com/willfretwell/heatmap-calendar-react/blob/master/images/heatmap.jpg) 11 | 12 | ## Installation 13 | 14 | Install: 15 | 16 | ```bash 17 | npm install heatmap-calendar-react 18 | ``` 19 | 20 | ## Usage 21 | 22 | Import: 23 | 24 | ```javascript 25 | import HeatmapCalendar from 'heatmap-calendar-react'; 26 | import 'heatmap-calendar-react/build/style.css'; 27 | ``` 28 | 29 | ### Examples 30 | 31 | Display empty calendar: 32 | 33 | ```javascript 34 | 35 | ``` 36 | 37 | Display basic calendar: 38 | 39 | ```javascript 40 | 47 | ``` 48 | 49 | Display calendar with heatmap density: 50 | 51 | ```javascript 52 | 59 | ``` 60 | 61 | Display calendar with heatmap density and tooltips: 62 | 63 | ```javascript 64 | 71 | ``` 72 | 73 | ### All options 74 | 75 | ```javascript 76 | console.log(dates)} 78 | //Optional --- When this is used you will receive the dates that the user selects, they will be able to select multiple dates. 79 | 80 | data={[ 81 | { date: '2018-01-01', total: 10, name: "Total" }, 82 | { date: '2018-01-02', total: 12, name: "Total" }, 83 | { date: '2018-01-03', total: 14, name: "Total" } 84 | ]} 85 | //Optional --- An array of objects that need a date field but can have a total (which is used to show density for the date) as well as a name field which is used for the tooltip. 86 | 87 | weekdayLabels={["","M","","W","","F",""]} 88 | //Optional --- You can pass custom weekday labels, this may need to be adjusted with css. 89 | 90 | showDate={boolean} 91 | //Optional --- Display the date inside the calendar day. 92 | 93 | highAccuracy={boolean} 94 | //Optional --- When activated, the days opacity is used to represent density as well as colour. 95 | 96 | showBorder={boolean} 97 | //Optional --- Apply a border around months. 98 | 99 | addGap={boolean} 100 | //Optional --- Apply a gap between months. 101 | 102 | showTooltip={boolean} 103 | //Optional --- Displays a tooltip when a day is hovered. Uses the name field in the data object. 104 | 105 | keyNavigation={boolean} 106 | //Optional --- Allows user to navigate their selection using the arrows keys and close their selection using the escape key. 107 | 108 | setYear={number} 109 | //Optional --- The default year will be the current year, however this can be changed by entering a numeric value e.g. 2010. 110 | 111 | onClick={(e, day) => console.log(e, day)} 112 | //Optional --- Instead on using receiveDates, you can use onClick. This will only allow the user to make one selection at a time. 113 | /> 114 | ``` 115 | 116 | ### Properties 117 | 118 | |Property | Usage | Default | Required | 119 | |:------------- |:-------------|:-----:|:-----:| 120 | | receiveDates | Receive the dates that the user selects, they will be able to select multiple dates. | none | no | 121 | | data | Array of objects. e.g. [{ date: '2018-01-03', total: 14, name: "Total" }] | none | no | 122 | | weekdayLabels | Array of strings | ["","M","","W","","F",""] | no | 123 | | showDate | Boolean - Display the date inside each calendar day | false | no | 124 | | highAccuracy | Boolean - Increases accuracy of heatmap colours | false | no | 125 | | showBorder | Boolean - Applies a border around months | false | no | 126 | | addGap | Boolean - Applies a gap between months | false | no | 127 | | showTooltip | Boolean - Displays a tooltip over days when hovered | false | no | 128 | | keyNavigation | Boolean - Navigate selection with arrow keys | false | no | 129 | | setYear | Display a different year | Current year | no | 130 | | onClick | Returns date clicked | none | no | 131 | 132 | ## Configuring colors 133 | 134 | ```css 135 | .colorScale0{background-color: #ddd;} 136 | .colorScale1{background-color: rgb(002, 184, 184);} 137 | .colorScale2{background-color: rgb(002, 146, 146);} 138 | .colorScale3{background-color: rgb(001, 107, 107);} 139 | .colorScale4{background-color: rgb(001, 068, 068);} 140 | ``` 141 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-modernist -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Heatmap Calendar 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /images/heatmap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willfretwell/heatmap-calendar-react/c7696ae6b01f6d9631b077e4d5bfa273b6c695a6/images/heatmap.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heatmap-calendar-react", 3 | "version": "1.0.1", 4 | "description": "\u0016A calendar with heatmap visualization. Based on GitHub's commit graph.", 5 | "author": "Will Fretwell", 6 | "main": "build/index.js", 7 | "scripts": { 8 | "build": "cross-env NODE_ENV=production webpack", 9 | "lint": "eslint --ext .js,.jsx --cache . && echo \"eslint: no lint errors found\" || true", 10 | "prepublishOnly": "npm run build", 11 | "start": "webpack-dev-server --progress --inline" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/willfretwell/heatmap-calendar-react.git" 16 | }, 17 | "keywords": [ 18 | "heatmap", 19 | "calendar", 20 | "react", 21 | "github", 22 | "commit", 23 | "chart" 24 | ], 25 | "license": "MIT", 26 | "peerDependencies": { 27 | "react": "^0.14.0 || ^15.0.0 || ^16.0.0" 28 | }, 29 | "devDependencies": { 30 | "babel-core": "^6.0.0", 31 | "babel-eslint": "^8.0.0", 32 | "babel-loader": "^7.0.0", 33 | "babel-preset-env": "^1.0.0", 34 | "babel-preset-react": "^6.0.0", 35 | "babel-register": "^6.0.0", 36 | "cross-env": "^5.0.0", 37 | "css-loader": "^0.28.11", 38 | "enzyme": "^3.0.0", 39 | "enzyme-adapter-react-16": "^1.0.0", 40 | "eslint": "^4.0.0", 41 | "eslint-config-airbnb": "^16.0.0", 42 | "eslint-plugin-import": "^2.0.0", 43 | "eslint-plugin-jsx-a11y": "^6.0.0", 44 | "eslint-plugin-react": "^7.0.0", 45 | "extract-text-webpack-plugin": "^3.0.2", 46 | "jest": "^22.0.0", 47 | "react": "^16.0.0", 48 | "react-dom": "^16.0.0", 49 | "react-test-renderer": "^16.0.0", 50 | "rimraf": "^2.0.0", 51 | "style-loader": "^0.20.3", 52 | "webpack": "^3.0.0", 53 | "webpack-dev-server": "^2.0.0" 54 | }, 55 | "bugs": { 56 | "url": "https://github.com/willfretwell/heatmap-calendar-react/issues" 57 | }, 58 | "homepage": "https://github.com/willfretwell/heatmap-calendar-react#readme" 59 | } 60 | -------------------------------------------------------------------------------- /src/dateHandlers.js: -------------------------------------------------------------------------------- 1 | function isLeapYear(date) { 2 | var year = date.getFullYear(); 3 | var answer = new Date(year, 1, 29).getDate() === 29; 4 | return answer; 5 | } 6 | 7 | function getDOY(date) { 8 | var dayCount = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; 9 | var m = date.getMonth(); 10 | var dayOfYear = dayCount[m] + date.getDate(); 11 | return m > 1 && isLeapYear(date) ? dayOfYear + 1 : dayOfYear; 12 | } 13 | 14 | function daysInMonth(month) { 15 | var dayCount = [31, isLeapYear(dateNow()) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 16 | return dayCount[month]; 17 | } 18 | 19 | function dateNow() { 20 | return new Date(Date.now()); 21 | } 22 | 23 | function daysInYear() { 24 | return isLeapYear(dateNow()) ? 366 : 365; 25 | } 26 | 27 | export default {getDOY, daysInYear, daysInMonth, dateNow}; -------------------------------------------------------------------------------- /src/getDifference.js: -------------------------------------------------------------------------------- 1 | function diff(arr1, arr2) { 2 | var set1 = new Set(arr1); 3 | var set2 = new Set(arr2); 4 | for (let item of set1) { 5 | if (set2.has(item)) { 6 | set2.delete(item); 7 | set1.delete(item); 8 | } 9 | } 10 | return Array.from(set1).concat( Array.from(set2) ); 11 | } 12 | 13 | export default diff; -------------------------------------------------------------------------------- /src/heatMapScale.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function gradient() { 4 | return
5 |
6 |
; 7 | } 8 | 9 | function box(highestTotal) { 10 | return
11 |
12 | 13 | 0 14 | 15 |
16 |
17 | 18 | {"1 - " + Math.round(highestTotal / 4 * 1)} 19 | 20 |
21 |
22 | 23 | {Math.round(highestTotal / 4 * 1) + " - " + Math.round(highestTotal / 4 * 2)} 24 | 25 |
26 |
27 | 28 | {Math.round(highestTotal / 4 * 2) + " - " + Math.round(highestTotal / 4 * 3)} 29 | 30 |
31 |
32 | 33 | {Math.round(highestTotal / 4 * 3) + " - " + highestTotal} 34 | 35 |
36 |
; 37 | } 38 | 39 | export default {gradient, box}; 40 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import findMonthString from "./monthToString"; 3 | import heatMapScale from "./heatMapScale"; 4 | import dateHandle from "./dateHandlers"; 5 | import diff from "./getDifference"; 6 | import "./style.css"; 7 | 8 | var dateNow = dateHandle.dateNow(); 9 | var daysInYear = dateHandle.daysInYear(); 10 | var getDOY = dateHandle.getDOY; 11 | 12 | var mouseDown; 13 | document.body.onmousedown = function() {mouseDown = true} 14 | document.body.onmouseup = function() {mouseDown = false} 15 | 16 | class HeatMapGraph extends Component { 17 | constructor(props) { 18 | super(props); 19 | 20 | this.state = { 21 | showDate: this.props.showDate || false, 22 | highAcc: this.props.highAccuracy || false, 23 | showBorder: this.props.showBorder || false, 24 | addGap: this.props.addGap || false, 25 | dates: [], 26 | selectedGroup: [], 27 | months: new Array(53).fill("blank") 28 | } 29 | } 30 | 31 | componentDidMount() { 32 | if (this.props.setYear !== undefined) { 33 | dateNow.setFullYear(this.props.setYear); 34 | } 35 | 36 | var data = this.props.data || []; 37 | var calendar = new Array(daysInYear).fill({}); 38 | var currentDate = new Date(dateNow.getTime()); 39 | var date = new Date(currentDate.getTime()); 40 | calendar.forEach((e, i) => { 41 | var day = {i: date.getTime(), date, value: "empty", desc: {total: 0}}; 42 | calendar[i] = day; 43 | date = new Date(currentDate.setDate(currentDate.getDate() - 1)); 44 | }); 45 | 46 | var highestTotal = 0; 47 | data.forEach(s => { 48 | if (s.total === undefined) { 49 | s.total = 1 50 | } 51 | var total = s.total; 52 | if (total >= highestTotal) { 53 | highestTotal = total; 54 | } 55 | }); 56 | 57 | if (0 in data) { 58 | if (typeof data[0].date === "string" || typeof data[0].date === "number") { 59 | data.forEach(e => { 60 | e.date = new Date(e.date); 61 | }) 62 | } 63 | } 64 | 65 | if (this.props.keyNavigation) { 66 | document.onkeydown = this.checkKey.bind(this); 67 | } 68 | 69 | this.setState({calendar: calendar.reverse(), highestTotal, data}); 70 | } 71 | 72 | checkKey(e) { 73 | e = e || window.event; 74 | switch (e.keyCode) { 75 | case 27: e.preventDefault(); this.resetDates(); break; 76 | case 37: e.preventDefault(); this.moveSelection(-7, e.shiftKey); break; 77 | case 38: e.preventDefault(); this.moveSelection(-1, e.shiftKey); break; 78 | case 39: e.preventDefault(); this.moveSelection(7, e.shiftKey); break; 79 | case 40: e.preventDefault(); this.moveSelection(1, e.shiftKey); break; 80 | default:; 81 | } 82 | } 83 | 84 | moveSelection(value, shiftKey) { 85 | var orignalDates = this.state.dates.slice(); 86 | var dates = orignalDates.map(date => { 87 | var newDate = new Date(date); 88 | return new Date(newDate.setDate(newDate.getDate() + (value))); 89 | }); 90 | if (shiftKey) { 91 | dates = dates.concat(orignalDates); 92 | } 93 | this.setState({dates}); 94 | if (this.props.receiveDates !== undefined) { 95 | this.props.receiveDates(dates); 96 | } 97 | } 98 | 99 | updateUISelected(selected, wait) { 100 | function run() { 101 | new Array(selected.length).fill("blank").forEach((item, i) => selected[i].lastChild.classList.toggle("selected")); 102 | } run(); 103 | setTimeout(function() { 104 | run(); 105 | }, wait); 106 | } 107 | 108 | setSelected(grab, number, year) { 109 | var selected = document.getElementsByClassName( grab + number + (year !== undefined ? year : "") ); 110 | this.updateUISelected(selected, 200); 111 | var dates = this.state.calendar.slice().filter(day => grab === "d" ? day.date.getDay() === number : day.date.getMonth() === number); 112 | this.returnDates(dates); 113 | } 114 | 115 | selectGroup(e, day) { 116 | var selected = e.currentTarget; 117 | var selectedGroup = this.state.selectedGroup.slice(); 118 | if (mouseDown) { 119 | this.updateUISelected([selected], 600); 120 | selectedGroup.push(day); 121 | this.setState({selectedGroup}); 122 | } 123 | } 124 | 125 | submitSelectGroup() { 126 | var selectedGroup = this.state.selectedGroup.slice(); 127 | this.returnDates(Array.from(new Set(selectedGroup))); 128 | this.setState({selectedGroup: []}); 129 | } 130 | 131 | returnDates(newDates) { 132 | var dates = this.state.dates.slice(); 133 | 134 | if (!Array.isArray(newDates)) { 135 | newDates = new Array(newDates); 136 | } 137 | 138 | var removeDates = []; 139 | var allowDates = []; 140 | 141 | newDates.forEach(d => { 142 | var allow = true; 143 | dates.forEach(date => { 144 | if (date === d.date) { 145 | allow = false; 146 | } 147 | }); 148 | allow ? allowDates.push(d.date) : removeDates.push(d.date); 149 | }); 150 | 151 | 0 in allowDates ? allowDates.forEach(date => dates.push(date)) : dates = diff(dates, removeDates); 152 | 153 | setTimeout(function() { 154 | if (this.props.receiveDates !== undefined) { 155 | this.setState({dates}); 156 | this.props.receiveDates(dates); 157 | } else { 158 | this.setState({dates: allowDates}); 159 | } 160 | }.bind(this), 10); 161 | } 162 | 163 | resetDates() { 164 | this.setState({dates:[]}); 165 | if (this.props.receiveDates !== undefined) { 166 | this.props.receiveDates([]); 167 | } 168 | } 169 | 170 | render() { 171 | var calendar = this.state.calendar; 172 | var highestTotal = this.state.highestTotal; 173 | var weekdayLabels = this.props.weekdayLabels || ["", "M", "", "W", "", "F", ""]; 174 | 175 | if (calendar !== undefined) { 176 | this.state.data.forEach(s => { 177 | var day = getDOY(s.date) + (daysInYear) - getDOY(dateNow) - 1; 178 | if (day >= daysInYear) { 179 | day = day - daysInYear; 180 | } 181 | if (s.total === undefined) { 182 | s.total = 1 183 | } 184 | var opacity = s.total / highestTotal; 185 | var bgColor = "colorScale0"; 186 | var decimal = Math.ceil((s.total / highestTotal) / 0.25) * 0.25; 187 | switch (decimal) { 188 | case 0: bgColor = "colorScale0"; break; 189 | case 0.25: bgColor = "colorScale1"; break; 190 | case 0.50: bgColor = "colorScale2"; break; 191 | case 0.75: bgColor = "colorScale3"; break; 192 | case 1: bgColor = "colorScale4"; break; 193 | default: bgColor = "colorScale0"; break; 194 | } 195 | if (calendar[day].date.getFullYear() === s.date.getFullYear()) { 196 | calendar[day] = {i: calendar[day].i, date: calendar[day].date, value: ("most" + Math.round(decimal)), desc: {label: s.name, total: s.total}, bgColor, opacity}; 197 | } 198 | }); 199 | } 200 | 201 | const oneYearAgo = new Date(dateNow); 202 | oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); 203 | var weekdayBalancer = new Array(oneYearAgo.getDay() + 1).fill("blank"); 204 | if (weekdayBalancer.length === 7) { 205 | weekdayBalancer = []; 206 | } 207 | 208 | var months = this.state.months.slice(); 209 | 210 | var blankLocations = []; 211 | 212 | if (calendar !== undefined) { 213 | var weekI = 0; 214 | var heatMap = calendar.map((day, i) => { 215 | var weekday = day.date.getDay(); 216 | var dayI = day.date.getDate(); 217 | var monthI = day.date.getMonth(); 218 | var year = day.date.getFullYear(); 219 | 220 | if (dayI === 1) { 221 | blankLocations.push(i + (blankLocations.length * 7)); 222 | if (this.state.addGap) { 223 | months.push("blank"); 224 | } 225 | } 226 | 227 | if (weekday === 0) { 228 | weekI++ 229 | if (dayI < 8) { 230 | if (this.state.addGap) { 231 | months[weekI + blankLocations.length] = {month: monthI, year} 232 | } else { 233 | months[weekI] = {month: monthI, year} 234 | } 235 | } 236 | } 237 | 238 | var selected = false; 239 | this.state.dates.forEach(date => { 240 | if (getDOY(date) === getDOY(day.date)) { 241 | selected = true; 242 | } 243 | }); 244 | 245 | var border = ((dateHandle.daysInMonth(monthI) - dayI < 7 && dateHandle.daysInMonth(monthI) - dayI >= 0) ? " last7" : "") + (dateHandle.daysInMonth(monthI) === dayI && weekday !== 6 ? " lastDay" : ""); 246 | 247 | var _onClick = this.props.onClick || function doNothing() {}; 248 | 249 | return
this.selectGroup(e, day)} 252 | onMouseDown={(e) => this.selectGroup(e, day)} 253 | onMouseUp={() => this.submitSelectGroup()} 254 | onClick={(e) => _onClick(e, day)} 255 | className={"heatMapGridItem " + day.value + " m" + monthI + year + " d" + weekday + (dayI === 1 ? " firstOfMonth" : "") + (this.state.showBorder ? border : "")} 256 | style={{fontSize: this.state.showDate ? "inherit" : 0}} 257 | > 258 | 259 | {dayI} 260 | 261 | {this.props.showTooltip && 262 | {day.desc.label !== undefined && {day.desc.label}: {day.desc.total}
} 263 | {findMonthString(monthI, true)} {dayI} {year} 264 |
} 265 |
266 |
267 | }); 268 | } 269 | 270 | if (this.state.addGap) { 271 | blankLocations.forEach(i => { 272 | var arr = new Array(7).fill(""); 273 | arr.forEach((e, ii) => { 274 | heatMap.splice(i, 0,
) 275 | }) 276 | }); 277 | if (heatMap !== undefined) { 278 | heatMap = heatMap.map(e => { 279 | var cx = require('classnames'); 280 | return React.cloneElement(e, { 281 | className: cx(e.props.className, "shrink"), 282 | }); 283 | }); 284 | } 285 | } 286 | 287 | return
288 |
289 |
{/*spacer*/}
290 | {months !== undefined && months.map((month, i) =>
this.setSelected("m", month.month, month.year)} className={this.state.addGap ? "heatMapMonthShrink" : "heatMapMonth"}>{findMonthString(month.month, true)}
)} 291 |
292 |
this.submitSelectGroup() : ""}> 293 | {weekdayLabels.map((dayL, i) =>
this.setSelected("d", i)} className="heatMapGridItem legend">{dayL}
)} 294 | {weekdayBalancer.map((a, i) =>
)} 295 | {heatMap} 296 |
297 | {this.state.highAcc ? heatMapScale.gradient() : heatMapScale.box(highestTotal)} 298 |
299 | } 300 | }; 301 | 302 | export default HeatMapGraph; 303 | -------------------------------------------------------------------------------- /src/monthToString.js: -------------------------------------------------------------------------------- 1 | function findMonthString(month, short) { 2 | switch (month) { 3 | case 0: return short ? "Jan" : "January"; 4 | case 1: return short ? "Feb" : "February"; 5 | case 2: return short ? "Mar" : "March"; 6 | case 3: return short ? "Apr" : "April"; 7 | case 4: return short ? "May" : "May"; 8 | case 5: return short ? "Jun" : "June"; 9 | case 6: return short ? "Jul" : "July"; 10 | case 7: return short ? "Aug" : "August"; 11 | case 8: return short ? "Sep" : "September"; 12 | case 9: return short ? "Oct" : "October"; 13 | case 10: return short ? "Nov" : "November"; 14 | case 11: return short ? "Dec" : "December"; 15 | default: ; 16 | } 17 | } 18 | 19 | export default findMonthString; -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .heatMapGrid { 2 | width: 70px; 3 | height: 70px; 4 | display: grid; 5 | grid-gap: 0px; 6 | grid-template-columns: repeat(7, 0fr); 7 | margin-bottom: 40px; 8 | margin-top: 10px; 9 | margin-left: 25px; 10 | transform: rotate(-90deg) scaleX(-1); 11 | font-size: 8.1px; 12 | } 13 | 14 | .heatMapGridItem { 15 | width: 13px; 16 | height: 13px; 17 | border: 1px solid #ffffff; 18 | transform: rotate(-90deg) scaleX(-1); 19 | transition: font-size .2s ease-in-out, box-shadow .2s ease-out; 20 | text-align: center; 21 | border-radius: 1px; 22 | } 23 | 24 | .heatMapGridItem.shrink { 25 | width: 11px!important; 26 | height: 11px!important; 27 | } 28 | 29 | .heatMapGridItem.empty { 30 | background-color: #ddd; 31 | color: #000; 32 | } 33 | 34 | .heatMapGridItem.empty:hover .heatMapGridItemBackground { 35 | box-shadow: inset 0 0 0 1px #ddd, inset 0 0 0 3px #fff; 36 | opacity: 1; 37 | } 38 | 39 | .heatMapGridItem.most1 { 40 | color: #f0f0f0; 41 | cursor: pointer; 42 | } 43 | 44 | .heatMapGridItem.most0 { 45 | cursor: pointer; 46 | } 47 | 48 | .heatMapGridItem.most1:hover .heatMapGridItemBackground, .heatMapGridItem.most0:hover .heatMapGridItemBackground { 49 | box-shadow: inset 0 0 0 1px #02a5a5, inset 0 0 0 3px #fff; 50 | } 51 | 52 | .heatMapGridItem.legend { 53 | background-color: #fff; 54 | color: #4f5858; 55 | font-weight: 900; 56 | } 57 | 58 | .heatMapGridItem.lastYear { 59 | background-color: #ddd; 60 | } 61 | 62 | .heatMapGridItem.lastYear:before { 63 | content: "|"; 64 | transform: rotate(45deg); 65 | position: absolute; 66 | left: 2px; 67 | top: -10px; 68 | font-size: 22px; 69 | color: #ccc; 70 | } 71 | 72 | .heatMapGridItem.last7 { 73 | border-right: 1px solid black; 74 | } 75 | 76 | .heatMapGridItem.lastDay { 77 | border-right: 1px solid black; 78 | border-bottom: 1px solid black; 79 | } 80 | 81 | 82 | .heatMapGridItemBackground.selected { 83 | box-shadow: inset 0 0 0 1px #02a5a5, inset 0 0 0 3px #fff; 84 | } 85 | 86 | .heatMapGridItemBackground.selected:before { 87 | position: absolute; 88 | content:"a"; 89 | top:0; 90 | left:0; 91 | width:100%; 92 | height:100%; 93 | opacity:1; 94 | background-color: #4f585859; 95 | } 96 | 97 | .heatMapGridItemBackground { 98 | background-color: #ddd; 99 | width: 100%; 100 | height: 100%; 101 | position: absolute; 102 | top: 0px; 103 | z-index: -1; 104 | transition: background-color .2s ease-in-out, box-shadow .2s ease-out; 105 | } 106 | 107 | .colorScale0{background-color: #ddd;} 108 | .colorScale1{background-color: rgb(002, 184, 184);} 109 | .colorScale2{background-color: rgb(002, 146, 146);} 110 | .colorScale3{background-color: rgb(001, 107, 107);} 111 | .colorScale4{background-color: rgb(001, 068, 068);} 112 | 113 | .heatMapGridItemTooltip { 114 | font-size: 12px; 115 | width: 100px; 116 | padding: 2px; 117 | background-color: #4f5858; 118 | color: #fff; 119 | position: absolute; 120 | display: none; 121 | right: 2px; 122 | bottom: 20px; 123 | border-radius: 3px; 124 | } 125 | 126 | span.heatMapGridItemTooltip::after { 127 | content: ""; 128 | background-color: #4f5858; 129 | position: absolute; 130 | right: 8px; 131 | bottom: -4px; 132 | width: 12px; 133 | height: 12px; 134 | z-index: -1; 135 | transform: skew(45deg) rotate(34deg); 136 | } 137 | 138 | .heatMapGridItem:hover .heatMapGridItemTooltip { 139 | display: block; 140 | } 141 | 142 | .heatMapMonths { 143 | font-size: 10px; 144 | display: grid; 145 | width: 10px; 146 | height: 10px; 147 | transform: rotate(-90deg) scaleX(-1); 148 | grid-gap: 2px; 149 | grid-template-columns: repeat(1, 0fr); 150 | grid-template-rows: repeat(52, 0fr); 151 | margin-top: 10px; 152 | margin-left: 25px; 153 | } 154 | 155 | .heatMapMonth { 156 | width: 13px; 157 | height: 13px; 158 | transform: rotate(-90deg) scaleX(-1); 159 | } 160 | 161 | .heatMapMonthShrink { 162 | width: 11px; 163 | height: 11px; 164 | transform: rotate(-90deg) scaleX(-1); 165 | } 166 | 167 | .heatMapScale { 168 | display: flex; 169 | justify-content: center; 170 | margin: 10px; 171 | width: 100%; 172 | } 173 | 174 | .heatMapGridItem.scale { 175 | margin: 1px; 176 | transform: rotate(0deg) scaleX(1); 177 | } 178 | 179 | .heatMapGridItemScaleGradient { 180 | width: 200px; 181 | height: 13px; 182 | margin: 1px; 183 | background: linear-gradient(to right, rgba(221,221,221,1) 0%, rgba(2,184,184,1) 25%, rgba(3,145,145,1) 50%, rgba(1,107,107,1) 75%, rgba(1,68,68,1) 100%); 184 | } 185 | 186 | .heatMapOptions { 187 | padding: 10px; 188 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const pak = require('./package.json'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | 6 | const nodeEnv = process.env.NODE_ENV || 'development'; 7 | 8 | const webpackConfig = { 9 | context: __dirname, 10 | entry: { 11 | 'heatmap-calendar-react': [ 12 | path.resolve(__dirname, 'src', 'index.jsx'), 13 | ], 14 | }, 15 | output: { 16 | path: path.resolve(__dirname), 17 | filename: 'index.js', 18 | library: 'HeatMapGraph', 19 | libraryTarget: 'umd', 20 | }, 21 | resolve: { 22 | extensions: ['.js', '.jsx'], 23 | modules: ['node_modules'], 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.jsx?$/, 29 | exclude: /(node_modules)/, 30 | use: [{ 31 | loader: 'babel-loader', 32 | }], 33 | }, 34 | { 35 | test: /\.*css$/, 36 | exclude: /(node_modules)/, 37 | use: ExtractTextPlugin.extract({ 38 | fallback: 'style-loader', 39 | use: [ 40 | 'css-loader' 41 | ] 42 | }) 43 | } 44 | ], 45 | }, 46 | plugins: [ 47 | new webpack.DefinePlugin({ 48 | 'process.env.NODE_ENV': JSON.stringify(nodeEnv), 49 | }), 50 | new ExtractTextPlugin({ 51 | filename: 'style.css', 52 | }), 53 | ], 54 | }; 55 | 56 | if (nodeEnv === 'development') { 57 | webpackConfig.devtool = 'source-map'; 58 | webpackConfig.devServer = { contentBase: './demo' }; 59 | webpackConfig.entry['heatmap-calendar-react'].unshift('webpack-dev-server/client?http://0.0.0.0:8080/'); 60 | webpackConfig.entry['heatmap-calendar-react'].push(path.resolve(__dirname, 'demo', 'demo.jsx')); 61 | webpackConfig.output.publicPath = '/'; 62 | } 63 | 64 | if (nodeEnv === 'demo') { 65 | webpackConfig.entry['heatmap-calendar-react'].push(path.resolve(__dirname, 'demo', 'demo.jsx')); 66 | webpackConfig.output.path = path.resolve(__dirname, 'demo'); 67 | } 68 | 69 | if (nodeEnv === 'development' || nodeEnv === 'demo') { 70 | webpackConfig.plugins.push(new webpack.DefinePlugin({ 71 | COMPONENT_NAME: JSON.stringify(pak.name), 72 | COMPONENT_VERSION: JSON.stringify(pak.version), 73 | COMPONENT_DESCRIPTION: JSON.stringify(pak.description), 74 | })); 75 | } 76 | 77 | if (nodeEnv === 'production') { 78 | webpackConfig.externals = { 79 | react: { 80 | root: 'React', 81 | commonjs2: 'react', 82 | commonjs: 'react', 83 | amd: 'react', 84 | }, 85 | }; 86 | webpackConfig.output.path = path.resolve(__dirname, 'build'); 87 | webpackConfig.plugins.push(new webpack.optimize.UglifyJsPlugin({ 88 | compress: { warnings: false }, 89 | sourceMap: false, 90 | })); 91 | } 92 | 93 | module.exports = webpackConfig; --------------------------------------------------------------------------------