├── .gitignore ├── index.js ├── images ├── screenshot_day_overview.png ├── screenshot_month_overview.png ├── screenshot_week_overview.png ├── screenshot_year_overview.png └── screenshot_global_overview.png ├── gulpfile.js ├── bower.json ├── dist ├── calendar-heatmap.min.css └── calendar-heatmap.min.js ├── LICENSE ├── package.json ├── src ├── calendar-heatmap.css └── calendar-heatmap.js ├── index.html └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('./dist/calendar-heatmap.min.js'); 2 | module.exports = 'g1b.calendar-heatmap'; 3 | -------------------------------------------------------------------------------- /images/screenshot_day_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/HEAD/images/screenshot_day_overview.png -------------------------------------------------------------------------------- /images/screenshot_month_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/HEAD/images/screenshot_month_overview.png -------------------------------------------------------------------------------- /images/screenshot_week_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/HEAD/images/screenshot_week_overview.png -------------------------------------------------------------------------------- /images/screenshot_year_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/HEAD/images/screenshot_year_overview.png -------------------------------------------------------------------------------- /images/screenshot_global_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/HEAD/images/screenshot_global_overview.png -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var concat = require('gulp-concat'); 3 | var cssnano = require('gulp-cssnano'); 4 | var uglify = require('gulp-uglify'); 5 | 6 | gulp.task('build', ['js', 'css']); 7 | 8 | gulp.task('css', function () { 9 | gulp.src('src/*.css') 10 | .pipe(cssnano()) 11 | .pipe(concat('calendar-heatmap.min.css')) 12 | .pipe(gulp.dest('./dist')); 13 | }); 14 | 15 | gulp.task('js', function () { 16 | gulp.src('src/*.js') 17 | .pipe(uglify()) 18 | .pipe(concat('calendar-heatmap.min.js')) 19 | .pipe(gulp.dest('./dist')); 20 | }); 21 | 22 | gulp.task('watch', function() { 23 | gulp.watch('src/**/*.+(js|html|css)', ['build']); 24 | }); 25 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-calendar-heatmap", 3 | "version": "0.3.2", 4 | "main": [ 5 | "dist/calendar-heatmap.min.js", 6 | "dist/calendar-heatmap.min.css" 7 | ], 8 | "homepage": "https://github.com/g1eb/angular-calendar-heatmap", 9 | "authors": [ 10 | "g1eb (https://g1eb.com)" 11 | ], 12 | "description": "Angular directive for d3.js calendar heatmap graph.", 13 | "moduleType": [], 14 | "keywords": [ 15 | "angular", 16 | "angularjs", 17 | "directive", 18 | "calendar", 19 | "heatmap", 20 | "graph", 21 | "visualization", 22 | "chart", 23 | "time", 24 | "d3js", 25 | "d3" 26 | ], 27 | "license": "MIT", 28 | "ignore": [ 29 | "**/.*", 30 | "node_modules", 31 | "bower_components", 32 | "test", 33 | "tests" 34 | ], 35 | "dependencies": { 36 | "angular": "^1.4.8", 37 | "d3": "^3.5.16", 38 | "moment": "^2.12.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /dist/calendar-heatmap.min.css: -------------------------------------------------------------------------------- 1 | .calendar-heatmap{user-select:none;-ms-user-select:none;-moz-user-select:none;-webkit-user-select:none}.calendar-heatmap .item{cursor:pointer}.calendar-heatmap .label{cursor:pointer;fill:#aaa;font-family:Helvetica,arial,Open Sans,sans-serif}.calendar-heatmap .button{cursor:pointer;fill:transparent;stroke-width:2;stroke:#aaa}.calendar-heatmap .button text{stroke-width:1;text-anchor:middle;fill:#aaa}.calendar-heatmap .heatmap-tooltip{pointer-events:none;position:absolute;z-index:1;width:250px;max-width:250px;overflow:hidden;padding:15px;font-size:12px;line-height:14px;color:#333;font-family:Helvetica,arial,Open Sans,sans-serif;background:hsla(0,0%,100%,.75)}.calendar-heatmap .heatmap-tooltip .header strong{display:inline-block;width:250px}.calendar-heatmap .heatmap-tooltip span{display:inline-block;width:50%;padding-right:10px;box-sizing:border-box}.calendar-heatmap .heatmap-tooltip .header strong,.calendar-heatmap .heatmap-tooltip span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 g1eb 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-calendar-heatmap", 3 | "version": "0.3.2", 4 | "description": "Angular directive for d3.js calendar heatmap graph.", 5 | "homepage": "https://github.com/g1eb/angular-calendar-heatmap#readme", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/g1eb/angular-calendar-heatmap.git" 9 | }, 10 | "author": "g1eb (https://g1eb.com)", 11 | "license": "MIT", 12 | "main": "index.js", 13 | "bugs": { 14 | "url": "https://github.com/g1eb/angular-calendar-heatmap/issues" 15 | }, 16 | "scripts": { 17 | "build": "gulp build" 18 | }, 19 | "dependencies": { 20 | "angular": "^1.4.8", 21 | "d3": "^3.5.16", 22 | "moment": "^2.13.0" 23 | }, 24 | "devDependencies": { 25 | "gulp": "^4.0.2", 26 | "gulp-concat": "^2.6.0", 27 | "gulp-cssnano": "^2.1.2", 28 | "gulp-uglify": "^3.0.2" 29 | }, 30 | "keywords": [ 31 | "angular", 32 | "angularjs", 33 | "directive", 34 | "calendar", 35 | "heatmap", 36 | "graph", 37 | "visualization", 38 | "chart", 39 | "time", 40 | "d3js", 41 | "d3" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/calendar-heatmap.css: -------------------------------------------------------------------------------- 1 | .calendar-heatmap { 2 | user-select: none; 3 | -ms-user-select: none; 4 | -moz-user-select: none; 5 | -webkit-user-select: none; 6 | } 7 | .calendar-heatmap .item { 8 | cursor: pointer; 9 | } 10 | .calendar-heatmap .label { 11 | cursor: pointer; 12 | fill: rgb(170, 170, 170); 13 | font-family: Helvetica, arial, 'Open Sans', sans-serif; 14 | } 15 | .calendar-heatmap .button { 16 | cursor: pointer; 17 | fill: transparent; 18 | stroke-width: 2; 19 | stroke: rgb(170, 170, 170); 20 | } 21 | .calendar-heatmap .button text { 22 | stroke-width: 1; 23 | text-anchor: middle; 24 | fill: rgb(170, 170, 170); 25 | } 26 | .calendar-heatmap .heatmap-tooltip { 27 | pointer-events: none; 28 | position: absolute; 29 | z-index: 9999; 30 | width: 250px; 31 | max-width: 250px; 32 | overflow: hidden; 33 | padding: 15px; 34 | font-size: 12px; 35 | line-height: 14px; 36 | color: rgb(51, 51, 51); 37 | font-family: Helvetica, arial, 'Open Sans', sans-serif; 38 | background: rgba(255, 255, 255, 0.75); 39 | } 40 | .calendar-heatmap .heatmap-tooltip .header strong { 41 | display: inline-block; 42 | width: 250px; 43 | } 44 | .calendar-heatmap .heatmap-tooltip span { 45 | display: inline-block; 46 | width: 50%; 47 | padding-right: 10px; 48 | box-sizing: border-box; 49 | } 50 | .calendar-heatmap .heatmap-tooltip span, 51 | .calendar-heatmap .heatmap-tooltip .header strong { 52 | white-space: nowrap; 53 | overflow: hidden; 54 | text-overflow: ellipsis; 55 | } 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Angular directive for d3.js calendar heatmap graph 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular directive for D3.js Calendar Heatmap 2 | 3 | This [d3.js](https://d3js.org/) heatmap representing time series data is used to visualize tracked time over the past year, showing details for each of the days on demand. Converted into an angular directive for your convenience :) 4 | 5 | Includes a global overview of multiple years and visualizations of year, month, week and day overview with zoom for details-on-demand. 6 | 7 | Inspired by [Github's contribution graph](https://help.github.com/articles/viewing-contributions-on-your-profile/#contributions-calendar) 8 | 9 | Based on [Calendar View](https://bl.ocks.org/mbostock/4063318) by [Mike Bostock](https://github.com/mbostock) 10 | Aaand [D3.js Calendar Heatmap](https://github.com/DKirwan/calendar-heatmap) by [Darragh Kirwan](https://github.com/DKirwan) 11 | 12 | ## Demo 13 | Click here for a live demo. 14 | 15 | ### Global overview 16 | [![Angular directive for d3.js calendar heatmap chart - global overview](https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/master/images/screenshot_global_overview.png)](https://rawgit.com/g1eb/angular-calendar-heatmap/master/) 17 | 18 | ### Year overview 19 | [![Angular directive for d3.js calendar heatmap chart - year overview](https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/master/images/screenshot_year_overview.png)](https://rawgit.com/g1eb/angular-calendar-heatmap/master/) 20 | 21 | ### Month overview 22 | [![Angular directive for d3.js calendar heatmap chart - month overview](https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/master/images/screenshot_month_overview.png)](https://rawgit.com/g1eb/angular-calendar-heatmap/master/) 23 | 24 | ### Week overview 25 | [![Angular directive for d3.js calendar heatmap chart - week overview](https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/master/images/screenshot_week_overview.png)](https://rawgit.com/g1eb/angular-calendar-heatmap/master/) 26 | 27 | ### Day overview 28 | [![Angular directive for d3.js calendar heatmap chart - day overview](https://raw.githubusercontent.com/g1eb/angular-calendar-heatmap/master/images/screenshot_day_overview.png)](https://rawgit.com/g1eb/angular-calendar-heatmap/master/) 29 | 30 | ## Installation 31 | 32 | 1) Install 'angular-calendar-heatmap' with bower 33 | 34 | ``` 35 | bower install angular-calendar-heatmap 36 | ``` 37 | 38 | Or: 39 | 40 | ``` 41 | npm install angular-calendar-heatmap 42 | ``` 43 | 44 | 2) Add 'g1b.calendar-heatmap' module to your app config 45 | 46 | 47 | ```javascript 48 | angular.module('myApp', [ 49 | 'g1b.calendar-heatmap', 50 | ..... 51 | ]) 52 | ``` 53 | 54 | 3) Use 'calendar-heatmap' directive in a view 55 | 56 | ```html 57 | 58 | ``` 59 | 60 | ### Attributes 61 | 62 | |Property | Usage | Default | Required | 63 | |:------------- |:-------------|:-----:|:-----:| 64 | | data | Time series data from max a year back | none | yes | 65 | | color | Theme hex color | #45ff00 | no | 66 | | overview | Initial overview type (choices are: year, month, day) | year | no | 67 | | handler | Handler function is fired on click of a time entry in daily overview | none | no | 68 | 69 | ### Example data 70 | 71 | Time series data where each day has a total time tracked (in seconds). 72 | Details, if provided, are shown in a tooltip on mouseover in different overviews. 73 | 74 | ``` 75 | var data = [{ 76 | "date": "2016-01-01", 77 | "total": 17164, 78 | "details": [{ 79 | "name": "Project 1", 80 | "date": "2016-01-01 12:30:45", 81 | "value": 9192 82 | }, { 83 | "name": "Project 2", 84 | "date": "2016-01-01 13:37:00", 85 | "value": 6753 86 | }, 87 | ..... 88 | { 89 | "name": "Project N", 90 | "date": "2016-01-01 17:52:41", 91 | "value": 1219 92 | }] 93 | }] 94 | ``` 95 | 96 | ### Optimization 97 | 98 | In some cases details array could be large and in order to fit the data into the tooltip a short summary is generated with distinct projects and their total tracked time for that date. 99 | In terms of optimization, summary data can be computed server-side and passed in using the ```summary``` attribute. 100 | And in addition to the data structure described above this would result in a summary dictionary with distinct project names and total values of tracked time in seconds, e.g.: 101 | 102 | ``` 103 | var data = [{ 104 | "date": "2016-01-01", 105 | "total": 17164, 106 | "details": [.....], 107 | "summary": [{ 108 | "name": "Project 1", 109 | "value": 9192 110 | }, { 111 | "name": "Project 2", 112 | "value": 6753 113 | }, 114 | ..... 115 | { 116 | "name": "Project N", 117 | "value": 1219 118 | }] 119 | }] 120 | ``` 121 | 122 | See [index.html](https://github.com/g1eb/angular-calendar-heatmap/blob/master/index.html) for an example implementation with random data or click here for a live demo. 123 | 124 | ## Angular2 component 125 | 126 | If you want to use this heatmap as an angular component (version 2.x), see [angular2-calendar-heatmap](https://github.com/g1eb/angular2-calendar-heatmap) 127 | 128 | ## React component 129 | 130 | If you want to use this heatmap as a React component, see [reactjs-calendar-heatmap](https://github.com/g1eb/reactjs-calendar-heatmap) 131 | 132 | ## Non-Angular version 133 | 134 | If you are looking for a plain vanilla javascript version of the heatmap, check out [calendar-heatmap-graph](https://github.com/g1eb/calendar-heatmap) 135 | 136 | ## Dependencies 137 | 138 | * [AngularJS](https://angularjs.org/) 139 | * [moment.js](https://momentjs.com/) 140 | * [d3.js](https://d3js.org/) 141 | -------------------------------------------------------------------------------- /dist/calendar-heatmap.min.js: -------------------------------------------------------------------------------- 1 | "use strict";angular.module("g1b.calendar-heatmap",[]).directive("calendarHeatmap",["$window",function(t){return{restrict:"E",scope:{data:"=",color:"=?",overview:"=?",handler:"=?"},replace:!0,template:'
',link:function(e,n){var a=5,r=1,o=1e3,i=200,l=10,s=40,c=20,u=500,d=!1,m=250,f=15;e.overview=e.overview||"global",e.history=["global"],e.selected={};var v=d3.select(n[0]).append("svg").attr("class","svg"),y=v.append("g"),h=v.append("g"),p=v.append("g"),w=d3.select(n[0]).append("div").attr("class","heatmap-tooltip").style("opacity",0),k=function(){var t=Math.round((moment()-moment().subtract(1,"year").startOf("week"))/864e5),e=Math.trunc(t/7),n=e+1;return n};e.$watch(function(){return n[0].clientWidth},function(t){t&&(o=t<1e3?1e3:t,l=(o-s)/k()-a,i=s+7*(l+a),v.attr({width:o,height:i}),e.data&&e.data[0].summary&&e.drawChart())}),angular.element(t).bind("resize",function(){e.$apply()}),e.$watch("data",function(t){t&&(t[0].summary||t.map(function(t){var e=t.details.reduce(function(t,e){return t[e.name]?t[e.name].value+=e.value:t[e.name]={value:e.value},t},{}),n=Object.keys(e).map(function(t){return{name:t,value:e[t].value}});return t.summary=n.sort(function(t,e){return e.value-t.value}),t}),e.drawChart())}),e.drawChart=function(){e.data&&("global"===e.overview?e.drawGlobalOverview():"year"===e.overview?e.drawYearOverview():"month"===e.overview?e.drawMonthOverview():"week"===e.overview?e.drawWeekOverview():"day"===e.overview&&e.drawDayOverview())},e.drawGlobalOverview=function(){e.history[e.history.length-1]!==e.overview&&e.history.push(e.overview);var t=moment(e.data[0].date).startOf("year"),n=moment(e.data[e.data.length-1].date).endOf("year"),r=d3.time.years(t,n).map(function(t){var n=moment(t);return{date:n,total:e.data.reduce(function(t,e){return moment(e.date).year()===n.year()&&(t+=e.total),t},0),summary:function(){var t=e.data.reduce(function(t,e){if(moment(e.date).year()===n.year())for(var a=0;a0&&(n+=""+(1===r?"1 day":r+" days")+"");var i=Math.floor((a-86400*r)/3600);i>0&&(n+=r>0?"
"+(1===i?"1 hour":i+" hours")+"
":""+(1===i?"1 hour":i+" hours")+"");var l=Math.floor((a-86400*r-3600*i)/60);if(l>0&&(n+=r>0||i>0?"
"+(1===l?"1 minute":l+" minutes")+"
":""+(1===l?"1 minute":l+" minutes")+""),n+="
",t.summary.length<=5)for(var s=0;s"+t.summary[s].name+"",n+=""+e.formatTime(t.summary[s].value)+"";else{for(var s=0;s<5;s++)n+="
"+t.summary[s].name+"",n+=""+e.formatTime(t.summary[s].value)+"
";n+="
";for(var c=0,s=5;sOther:",n+=""+e.formatTime(c)+""}for(var v=d(t.date.year())+2*f;o-v0?v(t.total):"transparent"}).on("click",function(t){d||0!==t.total&&(d=!0,e.selected=t,e.hideTooltip(),e.removeYearOverview(),e.overview="day",e.drawChart())}).on("mouseover",function(t){if(!d){var n=d3.select(this);!function s(){n=n.transition().duration(u).ease("ease-in").attr("x",function(t){return p(t)-(1.1*l-l)/2}).attr("y",function(t){return k(t)-(1.1*l-l)/2}).attr("width",1.1*l).attr("height",1.1*l).transition().duration(u).ease("ease-in").attr("x",function(t){return p(t)+(l-g(t))/2}).attr("y",function(t){return k(t)+(l-g(t))/2}).attr("width",function(t){return g(t)}).attr("height",function(t){return g(t)}).each("end",s)}();var a="";a+='
'+(t.total?e.formatTime(t.total):"No time")+" tracked
",a+="
on "+moment(t.date).format("dddd, MMM Do YYYY")+"

",angular.forEach(t.summary,function(t){a+="
"+t.name+"",a+=""+e.formatTime(t.value)+"
"});var r=p(t)+l;o-r
",a+="
"+(t.value?e.formatTime(t.value):"No time")+" tracked
",a+="
on "+moment(n).format("dddd, MMM Do YYYY")+"
";for(var r=b(moment(n).week())+f;o-r
",a+="
"+(t.value?e.formatTime(t.value):"No time")+" tracked
",a+="
on "+moment(n).format("dddd, MMM Do YYYY")+"
";var r=parseInt(d3.select(this.parentNode).attr("total"));M.domain([0,r]);for(var i=parseInt(d3.select(this).attr("x"))+M(t.value)/4+m/4;o-i

",a+="
"+(t.value?e.formatTime(t.value):"No time")+" tracked
",a+="
on "+moment(t.date).format("dddd, MMM Do YYYY HH:mm")+"
";for(var i=100*t.value/86400+r(moment(t.date));o-i=n&&e<=a?1:.1})}}).on("mouseout",function(){d||y.selectAll(".item-block").transition().duration(u).ease("ease-in").style("opacity",.5)}),h.selectAll(".label-project").remove(),h.selectAll(".label-project").data(t).enter().append("text").attr("class","label label-project").attr("x",a).attr("y",function(t){return n(t)+n.rangeBand()/2}).attr("min-height",function(){return n.rangeBand()}).style("text-anchor","left").attr("font-size",function(){return Math.floor(s/3)+"px"}).text(function(t){return t}).each(function(){for(var t=d3.select(this),e=t.node().getComputedTextLength(),n=t.text();e>1.5*s&&n.length>0;)n=n.slice(0,-1),t.text(n+"..."),e=t.node().getComputedTextLength()}).on("mouseenter",function(t){d||y.selectAll(".item-block").transition().duration(u).ease("ease-in").style("opacity",function(e){return e.name===t?1:.1})}).on("mouseout",function(){d||y.selectAll(".item-block").transition().duration(u).ease("ease-in").style("opacity",.5)}),e.drawButton()},e.drawButton=function(){p.selectAll(".button").remove();var t=p.append("g").attr("class","button button-back").style("opacity",0).on("click",function(){d||(d=!0,"year"===e.overview?e.removeYearOverview():"month"===e.overview?e.removeMonthOverview():"week"===e.overview?e.removeWeekOverview():"day"===e.overview&&e.removeDayOverview(),e.history.pop(),e.overview=e.history.pop(),e.drawChart())});t.append("circle").attr("cx",s/2.25).attr("cy",s/2.5).attr("r",l/2),t.append("text").attr("x",s/2.25).attr("y",s/2.5).attr("dy",function(){return Math.floor(o/100)/3}).attr("font-size",function(){return Math.floor(s/3)+"px"}).html("←"),t.transition().duration(u).ease("ease-in").style("opacity",1)},e.removeGlobalOverview=function(){y.selectAll(".item-block-year").transition().duration(u).ease("ease-out").style("opacity",0).remove(),h.selectAll(".label-year").remove()},e.removeYearOverview=function(){y.selectAll(".item-circle").transition().duration(u).ease("ease").style("opacity",0).remove(),h.selectAll(".label-day").remove(),h.selectAll(".label-month").remove(),e.hideBackButton()},e.removeMonthOverview=function(){y.selectAll(".item-block-month").selectAll(".item-block-rect").transition().duration(u).ease("ease-in").style("opacity",0).attr("x",function(t,e){return e%2===0?-o/3:o/3}).remove(),h.selectAll(".label-day").remove(),h.selectAll(".label-week").remove(),e.hideBackButton()},e.removeWeekOverview=function(){y.selectAll(".item-block-week").selectAll(".item-block-rect").transition().duration(u).ease("ease-in").style("opacity",0).attr("x",function(t,e){return e%2===0?-o/3:o/3}).remove(),h.selectAll(".label-day").remove(),h.selectAll(".label-week").remove(),e.hideBackButton()},e.removeDayOverview=function(){y.selectAll(".item-block").transition().duration(u).ease("ease-in").style("opacity",0).attr("x",function(t,e){return e%2===0?-o/3:o/3}).remove(),h.selectAll(".label-time").remove(),h.selectAll(".label-project").remove(),e.hideBackButton()},e.hideTooltip=function(){w.transition().duration(u/2).ease("ease-in").style("opacity",0)},e.hideBackButton=function(){p.selectAll(".button").transition().duration(u).ease("ease").style("opacity",0).remove()},e.formatTime=function(t){var e=Math.floor(t/3600),n=Math.floor((t-3600*e)/60),a="";return e>0&&(a+=1===e?"1 hour ":e+" hours "),n>0&&(a+=1===n?"1 minute":n+" minutes"),0===e&&0===n&&(a=Math.round(t)+" seconds"),a}}}}]); -------------------------------------------------------------------------------- /src/calendar-heatmap.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals d3 */ 4 | 5 | angular.module('g1b.calendar-heatmap', []). 6 | directive('calendarHeatmap', ['$window', function ($window) { 7 | 8 | return { 9 | restrict: 'E', 10 | scope: { 11 | data: '=', 12 | color: '=?', 13 | overview: '=?', 14 | handler: '=?' 15 | }, 16 | replace: true, 17 | template: '
', 18 | link: function (scope, element) { 19 | 20 | // Defaults 21 | var gutter = 5; 22 | var item_gutter = 1; 23 | var width = 1000; 24 | var height = 200; 25 | var item_size = 10; 26 | var label_padding = 40; 27 | var max_block_height = 20; 28 | var transition_duration = 500; 29 | var in_transition = false; 30 | 31 | // Tooltip defaults 32 | var tooltip_width = 250; 33 | var tooltip_padding = 15; 34 | 35 | // Initialize current overview type and history 36 | scope.overview = scope.overview || 'global'; 37 | scope.history = ['global']; 38 | scope.selected = {}; 39 | 40 | // Initialize svg element 41 | var svg = d3.select(element[0]) 42 | .append('svg') 43 | .attr('class', 'svg'); 44 | 45 | // Initialize main svg elements 46 | var items = svg.append('g'); 47 | var labels = svg.append('g'); 48 | var buttons = svg.append('g'); 49 | 50 | // Add tooltip to the same element as main svg 51 | var tooltip = d3.select(element[0]).append('div') 52 | .attr('class', 'heatmap-tooltip') 53 | .style('opacity', 0); 54 | 55 | var getNumberOfWeeks = function () { 56 | var dayIndex = Math.round((moment() - moment().subtract(1, 'year').startOf('week')) / 86400000); 57 | var colIndex = Math.trunc(dayIndex / 7); 58 | var numWeeks = colIndex + 1; 59 | return numWeeks; 60 | } 61 | 62 | scope.$watch(function () { 63 | return element[0].clientWidth; 64 | }, function ( w ) { 65 | if ( !w ) { return; } 66 | width = w < 1000 ? 1000 : w; 67 | item_size = ((width - label_padding) / getNumberOfWeeks() - gutter); 68 | height = label_padding + 7 * (item_size + gutter); 69 | svg.attr({'width': width, 'height': height}); 70 | if ( !!scope.data && !!scope.data[0].summary ) { 71 | scope.drawChart(); 72 | } 73 | }); 74 | 75 | angular.element($window).bind('resize', function () { 76 | scope.$apply(); 77 | }); 78 | 79 | // Watch for data availability 80 | scope.$watch('data', function (data) { 81 | if ( !data ) { return; } 82 | 83 | // Get daily summary if that was not provided 84 | if ( !data[0].summary ) { 85 | data.map(function (d) { 86 | var summary = d.details.reduce( function(uniques, project) { 87 | if ( !uniques[project.name] ) { 88 | uniques[project.name] = { 89 | 'value': project.value 90 | }; 91 | } else { 92 | uniques[project.name].value += project.value; 93 | } 94 | return uniques; 95 | }, {}); 96 | var unsorted_summary = Object.keys(summary).map(function (key) { 97 | return { 98 | 'name': key, 99 | 'value': summary[key].value 100 | }; 101 | }); 102 | d.summary = unsorted_summary.sort(function (a, b) { 103 | return b.value - a.value; 104 | }); 105 | return d; 106 | }); 107 | } 108 | 109 | // Draw the chart 110 | scope.drawChart(); 111 | }); 112 | 113 | 114 | /** 115 | * Draw the chart based on the current overview type 116 | */ 117 | scope.drawChart = function () { 118 | if ( !scope.data ) { return; } 119 | 120 | if ( scope.overview === 'global' ) { 121 | scope.drawGlobalOverview(); 122 | } else if ( scope.overview === 'year' ) { 123 | scope.drawYearOverview(); 124 | } else if ( scope.overview === 'month' ) { 125 | scope.drawMonthOverview(); 126 | } else if ( scope.overview === 'week' ) { 127 | scope.drawWeekOverview(); 128 | } else if ( scope.overview === 'day' ) { 129 | scope.drawDayOverview(); 130 | } 131 | }; 132 | 133 | 134 | /** 135 | * Draw global overview (multiple years) 136 | */ 137 | scope.drawGlobalOverview = function () { 138 | 139 | // Add current overview to the history 140 | if ( scope.history[scope.history.length-1] !== scope.overview ) { 141 | scope.history.push(scope.overview); 142 | } 143 | 144 | // Define start and end of the dataset 145 | var start = moment(scope.data[0].date).startOf('year'); 146 | var end = moment(scope.data[scope.data.length-1].date).endOf('year'); 147 | 148 | // Define array of years and total values 149 | var year_data = d3.time.years(start, end).map(function (d) { 150 | var date = moment(d); 151 | return { 152 | 'date': date, 153 | 'total': scope.data.reduce(function (prev, current) { 154 | if ( moment(current.date).year() === date.year() ) { 155 | prev += current.total; 156 | } 157 | return prev; 158 | }, 0), 159 | 'summary': function () { 160 | var summary = scope.data.reduce(function (summary, d) { 161 | if ( moment(d.date).year() === date.year() ) { 162 | for ( var i = 0; i < d.summary.length; i++ ) { 163 | if ( !summary[d.summary[i].name] ) { 164 | summary[d.summary[i].name] = { 165 | 'value': d.summary[i].value, 166 | }; 167 | } else { 168 | summary[d.summary[i].name].value += d.summary[i].value; 169 | } 170 | } 171 | } 172 | return summary; 173 | }, {}); 174 | var unsorted_summary = Object.keys(summary).map(function (key) { 175 | return { 176 | 'name': key, 177 | 'value': summary[key].value 178 | }; 179 | }); 180 | return unsorted_summary.sort(function (a, b) { 181 | return b.value - a.value; 182 | }); 183 | }(), 184 | }; 185 | }); 186 | 187 | // Calculate max value of all the years in the dataset 188 | var max_value = d3.max(year_data, function (d) { 189 | return d.total; 190 | }); 191 | 192 | // Define year labels and axis 193 | var year_labels = d3.time.years(start, end).map(function (d) { 194 | return moment(d); 195 | }); 196 | var yearScale = d3.scale.ordinal() 197 | .rangeRoundBands([0, width], 0.05) 198 | .domain(year_labels.map(function(d) { 199 | return d.year(); 200 | })); 201 | 202 | // Add global data items to the overview 203 | items.selectAll('.item-block-year').remove(); 204 | var item_block = items.selectAll('.item-block-year') 205 | .data(year_data) 206 | .enter() 207 | .append('rect') 208 | .attr('class', 'item item-block-year') 209 | .attr('width', function () { 210 | return (width - label_padding) / year_labels.length - gutter * 5; 211 | }) 212 | .attr('height', function () { 213 | return height - label_padding; 214 | }) 215 | .attr('transform', function (d) { 216 | return 'translate(' + yearScale(d.date.year()) + ',' + tooltip_padding * 2 + ')'; 217 | }) 218 | .attr('fill', function (d) { 219 | var color = d3.scale.linear() 220 | .range(['#ffffff', scope.color || '#ff4500']) 221 | .domain([-0.15 * max_value, max_value]); 222 | return color(d.total) || '#ff4500'; 223 | }) 224 | .on('click', function (d) { 225 | if ( scope.in_transition ) { return; } 226 | 227 | // Set in_transition flag 228 | scope.in_transition = true; 229 | 230 | // Set selected date to the one clicked on 231 | scope.selected = d; 232 | 233 | // Hide tooltip 234 | scope.hideTooltip(); 235 | 236 | // Remove all global overview related items and labels 237 | scope.removeGlobalOverview(); 238 | 239 | // Redraw the chart 240 | scope.overview = 'year'; 241 | scope.drawChart(); 242 | }) 243 | .style('opacity', 0) 244 | .on('mouseover', function(d) { 245 | if ( scope.in_transition ) { return; } 246 | 247 | // Construct tooltip 248 | var tooltip_html = ''; 249 | tooltip_html += '
Total time tracked:'; 250 | 251 | var sec = parseInt(d.total, 10); 252 | var days = Math.floor(sec / 86400); 253 | if ( days > 0 ) { 254 | tooltip_html += '' + (days === 1 ? '1 day' : days + ' days') + '
'; 255 | } 256 | var hours = Math.floor((sec - (days * 86400)) / 3600); 257 | if ( hours > 0 ) { 258 | if ( days > 0 ) { 259 | tooltip_html += '
' + (hours === 1 ? '1 hour' : hours + ' hours') + '
'; 260 | } else { 261 | tooltip_html += '' + (hours === 1 ? '1 hour' : hours + ' hours') + '
'; 262 | } 263 | } 264 | var minutes = Math.floor((sec - (days * 86400) - (hours * 3600)) / 60); 265 | if ( minutes > 0 ) { 266 | if ( days > 0 || hours > 0 ) { 267 | tooltip_html += '
' + (minutes === 1 ? '1 minute' : minutes + ' minutes') + '
'; 268 | } else { 269 | tooltip_html += '' + (minutes === 1 ? '1 minute' : minutes + ' minutes') + ''; 270 | } 271 | } 272 | tooltip_html += '
'; 273 | 274 | // Add summary to the tooltip 275 | if ( d.summary.length <= 5 ) { 276 | for ( var i = 0; i < d.summary.length; i++ ) { 277 | tooltip_html += '
' + d.summary[i].name + ''; 278 | tooltip_html += '' + scope.formatTime(d.summary[i].value) + '
'; 279 | }; 280 | } else { 281 | for ( var i = 0; i < 5; i++ ) { 282 | tooltip_html += '
' + d.summary[i].name + ''; 283 | tooltip_html += '' + scope.formatTime(d.summary[i].value) + '
'; 284 | }; 285 | tooltip_html += '
'; 286 | 287 | var other_projects_sum = 0; 288 | for ( var i = 5; i < d.summary.length; i++ ) { 289 | other_projects_sum =+ d.summary[i].value; 290 | }; 291 | tooltip_html += '
Other:'; 292 | tooltip_html += '' + scope.formatTime(other_projects_sum) + '
'; 293 | } 294 | 295 | // Calculate tooltip position 296 | var x = yearScale(d.date.year()) + tooltip_padding * 2; 297 | while ( width - x < (tooltip_width + tooltip_padding * 5) ) { 298 | x -= 10; 299 | } 300 | var y = tooltip_padding * 3; 301 | 302 | // Show tooltip 303 | tooltip.html(tooltip_html) 304 | .style('left', x + 'px') 305 | .style('top', y + 'px') 306 | .transition() 307 | .duration(transition_duration / 2) 308 | .ease('ease-in') 309 | .style('opacity', 1); 310 | }) 311 | .on('mouseout', function () { 312 | if ( scope.in_transition ) { return; } 313 | scope.hideTooltip(); 314 | }) 315 | .transition() 316 | .delay(function (d, i) { 317 | return transition_duration * (i + 1) / 10; 318 | }) 319 | .duration(function () { 320 | return transition_duration; 321 | }) 322 | .ease('ease-in') 323 | .style('opacity', 1) 324 | .call(function (transition, callback) { 325 | if ( transition.empty() ) { 326 | callback(); 327 | } 328 | var n = 0; 329 | transition 330 | .each(function() { ++n; }) 331 | .each('end', function() { 332 | if ( !--n ) { 333 | callback.apply(this, arguments); 334 | } 335 | }); 336 | }, function() { 337 | scope.in_transition = false; 338 | }); 339 | 340 | // Add year labels 341 | labels.selectAll('.label-year').remove(); 342 | labels.selectAll('.label-year') 343 | .data(year_labels) 344 | .enter() 345 | .append('text') 346 | .attr('class', 'label label-year') 347 | .attr('font-size', function () { 348 | return Math.floor(label_padding / 3) + 'px'; 349 | }) 350 | .text(function (d) { 351 | return d.year(); 352 | }) 353 | .attr('x', function (d) { 354 | return yearScale(d.year()); 355 | }) 356 | .attr('y', label_padding / 2) 357 | .on('mouseenter', function (year_label) { 358 | if ( scope.in_transition ) { return; } 359 | 360 | items.selectAll('.item-block-year') 361 | .transition() 362 | .duration(transition_duration) 363 | .ease('ease-in') 364 | .style('opacity', function (d) { 365 | return ( moment(d.date).year() === year_label.year() ) ? 1 : 0.1; 366 | }); 367 | }) 368 | .on('mouseout', function () { 369 | if ( scope.in_transition ) { return; } 370 | 371 | items.selectAll('.item-block-year') 372 | .transition() 373 | .duration(transition_duration) 374 | .ease('ease-in') 375 | .style('opacity', 1); 376 | }) 377 | .on('click', function (d) { 378 | if ( scope.in_transition ) { return; } 379 | 380 | // Set in_transition flag 381 | scope.in_transition = true; 382 | 383 | // Set selected year to the one clicked on 384 | scope.selected = { date: d }; 385 | 386 | // Hide tooltip 387 | scope.hideTooltip(); 388 | 389 | // Remove all global overview related items and labels 390 | scope.removeGlobalOverview(); 391 | 392 | // Redraw the chart 393 | scope.overview = 'year'; 394 | scope.drawChart(); 395 | }); 396 | }, 397 | 398 | 399 | /** 400 | * Draw year overview 401 | */ 402 | scope.drawYearOverview = function () { 403 | // Add current overview to the history 404 | if ( scope.history[scope.history.length-1] !== scope.overview ) { 405 | scope.history.push(scope.overview); 406 | } 407 | 408 | var year_ago = moment().startOf('day').subtract(1, 'year'); 409 | var max_value = d3.max(scope.data, function (d) { 410 | return d.total; 411 | }); 412 | 413 | // Define start and end date of the selected year 414 | var start_of_year = moment(scope.selected.date).startOf('year'); 415 | var end_of_year = moment(scope.selected.date).endOf('year'); 416 | 417 | // Filter data down to the selected year 418 | var year_data = scope.data.filter(function (d) { 419 | return start_of_year <= moment(d.date) && moment(d.date) < end_of_year; 420 | }); 421 | 422 | // Calculate max value of the year data 423 | var max_value = d3.max(year_data, function (d) { 424 | return d.total; 425 | }); 426 | 427 | var color = d3.scale.linear() 428 | .range(['#ffffff', scope.color || '#ff4500']) 429 | .domain([-0.15 * max_value, max_value]); 430 | 431 | var calcItemX = function (d) { 432 | var date = moment(d.date); 433 | var dayIndex = Math.round((date - moment(start_of_year).startOf('week')) / 86400000); 434 | var colIndex = Math.trunc(dayIndex / 7); 435 | return colIndex * (item_size + gutter) + label_padding; 436 | }; 437 | var calcItemY = function (d) { 438 | return label_padding + moment(d.date).weekday() * (item_size + gutter); 439 | }; 440 | var calcItemSize = function (d) { 441 | if ( max_value <= 0 ) { return item_size; } 442 | return item_size * 0.75 + (item_size * d.total / max_value) * 0.25; 443 | }; 444 | 445 | items.selectAll('.item-circle').remove(); 446 | items.selectAll('.item-circle') 447 | .data(year_data) 448 | .enter() 449 | .append('rect') 450 | .attr('class', 'item item-circle') 451 | .style('opacity', 0) 452 | .attr('x', function (d) { 453 | return calcItemX(d) + (item_size - calcItemSize(d)) / 2; 454 | }) 455 | .attr('y', function (d) { 456 | return calcItemY(d) + (item_size - calcItemSize(d)) / 2; 457 | }) 458 | .attr('rx', function (d) { 459 | return calcItemSize(d); 460 | }) 461 | .attr('ry', function (d) { 462 | return calcItemSize(d); 463 | }) 464 | .attr('width', function (d) { 465 | return calcItemSize(d); 466 | }) 467 | .attr('height', function (d) { 468 | return calcItemSize(d); 469 | }) 470 | .attr('fill', function (d) { 471 | return ( d.total > 0 ) ? color(d.total) : 'transparent'; 472 | }) 473 | .on('click', function (d) { 474 | if ( in_transition ) { return; } 475 | 476 | // Don't transition if there is no data to show 477 | if ( d.total === 0 ) { return; } 478 | 479 | in_transition = true; 480 | 481 | // Set selected date to the one clicked on 482 | scope.selected = d; 483 | 484 | // Hide tooltip 485 | scope.hideTooltip(); 486 | 487 | // Remove all year overview related items and labels 488 | scope.removeYearOverview(); 489 | 490 | // Redraw the chart 491 | scope.overview = 'day'; 492 | scope.drawChart(); 493 | }) 494 | .on('mouseover', function (d) { 495 | if ( in_transition ) { return; } 496 | 497 | // Pulsating animation 498 | var circle = d3.select(this); 499 | (function repeat() { 500 | circle = circle.transition() 501 | .duration(transition_duration) 502 | .ease('ease-in') 503 | .attr('x', function (d) { 504 | return calcItemX(d) - (item_size * 1.1 - item_size) / 2; 505 | }) 506 | .attr('y', function (d) { 507 | return calcItemY(d) - (item_size * 1.1 - item_size) / 2; 508 | }) 509 | .attr('width', item_size * 1.1) 510 | .attr('height', item_size * 1.1) 511 | .transition() 512 | .duration(transition_duration) 513 | .ease('ease-in') 514 | .attr('x', function (d) { 515 | return calcItemX(d) + (item_size - calcItemSize(d)) / 2; 516 | }) 517 | .attr('y', function (d) { 518 | return calcItemY(d) + (item_size - calcItemSize(d)) / 2; 519 | }) 520 | .attr('width', function (d) { 521 | return calcItemSize(d); 522 | }) 523 | .attr('height', function (d) { 524 | return calcItemSize(d); 525 | }) 526 | .each('end', repeat); 527 | })(); 528 | 529 | // Construct tooltip 530 | var tooltip_html = ''; 531 | tooltip_html += '
' + (d.total ? scope.formatTime(d.total) : 'No time') + ' tracked
'; 532 | tooltip_html += '
on ' + moment(d.date).format('dddd, MMM Do YYYY') + '

'; 533 | 534 | // Add summary to the tooltip 535 | angular.forEach(d.summary, function (d) { 536 | tooltip_html += '
' + d.name + ''; 537 | tooltip_html += '' + scope.formatTime(d.value) + '
'; 538 | }); 539 | 540 | // Calculate tooltip position 541 | var x = calcItemX(d) + item_size; 542 | if ( width - x < (tooltip_width + tooltip_padding * 3) ) { 543 | x -= tooltip_width + tooltip_padding * 2; 544 | } 545 | var y = calcItemY(d) + item_size; 546 | 547 | // Show tooltip 548 | tooltip.html(tooltip_html) 549 | .style('left', x + 'px') 550 | .style('top', y + 'px') 551 | .transition() 552 | .duration(transition_duration / 2) 553 | .ease('ease-in') 554 | .style('opacity', 1); 555 | }) 556 | .on('mouseout', function () { 557 | if ( in_transition ) { return; } 558 | 559 | // Set circle radius back to what it's supposed to be 560 | d3.select(this).transition() 561 | .duration(transition_duration / 2) 562 | .ease('ease-in') 563 | .attr('x', function (d) { 564 | return calcItemX(d) + (item_size - calcItemSize(d)) / 2; 565 | }) 566 | .attr('y', function (d) { 567 | return calcItemY(d) + (item_size - calcItemSize(d)) / 2; 568 | }) 569 | .attr('width', function (d) { 570 | return calcItemSize(d); 571 | }) 572 | .attr('height', function (d) { 573 | return calcItemSize(d); 574 | }); 575 | 576 | // Hide tooltip 577 | scope.hideTooltip(); 578 | }) 579 | .transition() 580 | .delay( function () { 581 | return (Math.cos(Math.PI * Math.random()) + 1) * transition_duration; 582 | }) 583 | .duration(function () { 584 | return transition_duration; 585 | }) 586 | .ease('ease-in') 587 | .style('opacity', 1) 588 | .call(function (transition, callback) { 589 | if ( transition.empty() ) { 590 | callback(); 591 | } 592 | var n = 0; 593 | transition 594 | .each(function() { ++n; }) 595 | .each('end', function() { 596 | if ( !--n ) { 597 | callback.apply(this, arguments); 598 | } 599 | }); 600 | }, function() { 601 | in_transition = false; 602 | }); 603 | 604 | // Add month labels 605 | var month_labels = d3.time.months(start_of_year, end_of_year); 606 | var monthScale = d3.scale.linear() 607 | .range([0, width]) 608 | .domain([0, month_labels.length]); 609 | labels.selectAll('.label-month').remove(); 610 | labels.selectAll('.label-month') 611 | .data(month_labels) 612 | .enter() 613 | .append('text') 614 | .attr('class', 'label label-month') 615 | .attr('font-size', function () { 616 | return Math.floor(label_padding / 3) + 'px'; 617 | }) 618 | .text(function (d) { 619 | return d.toLocaleDateString('en-us', {month: 'short'}); 620 | }) 621 | .attr('x', function (d, i) { 622 | return monthScale(i) + (monthScale(i) - monthScale(i-1)) / 2; 623 | }) 624 | .attr('y', label_padding / 2) 625 | .on('mouseenter', function (d) { 626 | if ( in_transition ) { return; } 627 | 628 | var selected_month = moment(d); 629 | items.selectAll('.item-circle') 630 | .transition() 631 | .duration(transition_duration) 632 | .ease('ease-in') 633 | .style('opacity', function (d) { 634 | return moment(d.date).isSame(selected_month, 'month') ? 1 : 0.1; 635 | }); 636 | }) 637 | .on('mouseout', function () { 638 | if ( in_transition ) { return; } 639 | 640 | items.selectAll('.item-circle') 641 | .transition() 642 | .duration(transition_duration) 643 | .ease('ease-in') 644 | .style('opacity', 1); 645 | }) 646 | .on('click', function (d) { 647 | if ( in_transition ) { return; } 648 | 649 | // Check month data 650 | var month_data = scope.data.filter(function (e) { 651 | return moment(d).startOf('month') <= moment(e.date) && moment(e.date) < moment(d).endOf('month'); 652 | }); 653 | 654 | // Don't transition if there is no data to show 655 | if ( !month_data.length ) { return; } 656 | 657 | // Set selected month to the one clicked on 658 | scope.selected = {date: d}; 659 | 660 | in_transition = true; 661 | 662 | // Hide tooltip 663 | scope.hideTooltip(); 664 | 665 | // Remove all year overview related items and labels 666 | scope.removeYearOverview(); 667 | 668 | // Redraw the chart 669 | scope.overview = 'month'; 670 | scope.drawChart(); 671 | }); 672 | 673 | // Add day labels 674 | var day_labels = d3.time.days(moment().startOf('week'), moment().endOf('week')); 675 | var dayScale = d3.scale.ordinal() 676 | .rangeRoundBands([label_padding, height]) 677 | .domain(day_labels.map(function (d) { 678 | return moment(d).weekday(); 679 | })); 680 | labels.selectAll('.label-day').remove(); 681 | labels.selectAll('.label-day') 682 | .data(day_labels) 683 | .enter() 684 | .append('text') 685 | .attr('class', 'label label-day') 686 | .attr('x', label_padding / 3) 687 | .attr('y', function (d, i) { 688 | return dayScale(i) + dayScale.rangeBand() / 1.75; 689 | }) 690 | .style('text-anchor', 'left') 691 | .attr('font-size', function () { 692 | return Math.floor(label_padding / 3) + 'px'; 693 | }) 694 | .text(function (d) { 695 | return moment(d).format('dddd')[0]; 696 | }) 697 | .on('mouseenter', function (d) { 698 | if ( in_transition ) { return; } 699 | 700 | var selected_day = moment(d); 701 | items.selectAll('.item-circle') 702 | .transition() 703 | .duration(transition_duration) 704 | .ease('ease-in') 705 | .style('opacity', function (d) { 706 | return (moment(d.date).day() === selected_day.day()) ? 1 : 0.1; 707 | }); 708 | }) 709 | .on('mouseout', function () { 710 | if ( in_transition ) { return; } 711 | 712 | items.selectAll('.item-circle') 713 | .transition() 714 | .duration(transition_duration) 715 | .ease('ease-in') 716 | .style('opacity', 1); 717 | }); 718 | 719 | // Add button to switch back to previous overview 720 | scope.drawButton(); 721 | }; 722 | 723 | 724 | /** 725 | * Draw month overview 726 | */ 727 | scope.drawMonthOverview = function () { 728 | // Add current overview to the history 729 | if ( scope.history[scope.history.length-1] !== scope.overview ) { 730 | scope.history.push(scope.overview); 731 | } 732 | 733 | // Define beginning and end of the month 734 | var start_of_month = moment(scope.selected.date).startOf('month'); 735 | var end_of_month = moment(scope.selected.date).endOf('month'); 736 | 737 | // Filter data down to the selected month 738 | var month_data = scope.data.filter(function (d) { 739 | return start_of_month <= moment(d.date) && moment(d.date) < end_of_month; 740 | }); 741 | var max_value = d3.max(month_data, function (d) { 742 | return d3.max(d.summary, function (d) { 743 | return d.value; 744 | }); 745 | }); 746 | 747 | // Define day labels and axis 748 | var day_labels = d3.time.days(moment().startOf('week'), moment().endOf('week')); 749 | var dayScale = d3.scale.ordinal() 750 | .rangeRoundBands([label_padding, height]) 751 | .domain(day_labels.map(function (d) { 752 | return moment(d).weekday(); 753 | })); 754 | 755 | // Define week labels and axis 756 | var week_labels = [start_of_month.clone()]; 757 | while ( start_of_month.week() !== end_of_month.week() ) { 758 | week_labels.push(start_of_month.add(1, 'week').clone()); 759 | } 760 | var weekScale = d3.scale.ordinal() 761 | .rangeRoundBands([label_padding, width], 0.05) 762 | .domain(week_labels.map(function(weekday) { 763 | return weekday.week(); 764 | })); 765 | 766 | // Add month data items to the overview 767 | items.selectAll('.item-block-month').remove(); 768 | var item_block = items.selectAll('.item-block-month') 769 | .data(month_data) 770 | .enter() 771 | .append('g') 772 | .attr('class', 'item item-block-month') 773 | .attr('width', function () { 774 | return (width - label_padding) / week_labels.length - gutter * 5; 775 | }) 776 | .attr('height', function () { 777 | return Math.min(dayScale.rangeBand(), max_block_height); 778 | }) 779 | .attr('transform', function (d) { 780 | return 'translate(' + weekScale(moment(d.date).week()) + ',' + ((dayScale(moment(d.date).weekday()) + dayScale.rangeBand() / 1.75) - 15)+ ')'; 781 | }) 782 | .attr('total', function (d) { 783 | return d.total; 784 | }) 785 | .attr('date', function (d) { 786 | return d.date; 787 | }) 788 | .attr('offset', 0) 789 | .on('click', function (d) { 790 | if ( in_transition ) { return; } 791 | 792 | // Don't transition if there is no data to show 793 | if ( d.total === 0 ) { return; } 794 | 795 | in_transition = true; 796 | 797 | // Set selected date to the one clicked on 798 | scope.selected = d; 799 | 800 | // Hide tooltip 801 | scope.hideTooltip(); 802 | 803 | // Remove all month overview related items and labels 804 | scope.removeMonthOverview(); 805 | 806 | // Redraw the chart 807 | scope.overview = 'day'; 808 | scope.drawChart(); 809 | }); 810 | 811 | var item_width = (width - label_padding) / week_labels.length - gutter * 5; 812 | var itemScale = d3.scale.linear() 813 | .rangeRound([0, item_width]); 814 | 815 | item_block.selectAll('.item-block-rect') 816 | .data(function (d) { 817 | return d.summary; 818 | }) 819 | .enter() 820 | .append('rect') 821 | .attr('class', 'item item-block-rect') 822 | .attr('x', function (d) { 823 | var total = parseInt(d3.select(this.parentNode).attr('total')); 824 | var offset = parseInt(d3.select(this.parentNode).attr('offset')); 825 | itemScale.domain([0, total]); 826 | d3.select(this.parentNode).attr('offset', offset + itemScale(d.value)); 827 | return offset; 828 | }) 829 | .attr('width', function (d) { 830 | var total = parseInt(d3.select(this.parentNode).attr('total')); 831 | itemScale.domain([0, total]); 832 | return Math.max((itemScale(d.value) - item_gutter), 1) 833 | }) 834 | .attr('height', function () { 835 | return Math.min(dayScale.rangeBand(), max_block_height); 836 | }) 837 | .attr('fill', function (d) { 838 | var color = d3.scale.linear() 839 | .range(['#ffffff', scope.color || '#ff4500']) 840 | .domain([-0.15 * max_value, max_value]); 841 | return color(d.value) || '#ff4500'; 842 | }) 843 | .style('opacity', 0) 844 | .on('mouseover', function(d) { 845 | if ( in_transition ) { return; } 846 | 847 | // Get date from the parent node 848 | var date = new Date(d3.select(this.parentNode).attr('date')); 849 | 850 | // Construct tooltip 851 | var tooltip_html = ''; 852 | tooltip_html += '
' + d.name + '

'; 853 | tooltip_html += '
' + (d.value ? scope.formatTime(d.value) : 'No time') + ' tracked
'; 854 | tooltip_html += '
on ' + moment(date).format('dddd, MMM Do YYYY') + '
'; 855 | 856 | // Calculate tooltip position 857 | var x = weekScale(moment(date).week()) + tooltip_padding; 858 | while ( width - x < (tooltip_width + tooltip_padding * 3) ) { 859 | x -= 10; 860 | } 861 | var y = dayScale(moment(date).weekday()) + tooltip_padding * 2; 862 | 863 | // Show tooltip 864 | tooltip.html(tooltip_html) 865 | .style('left', x + 'px') 866 | .style('top', y + 'px') 867 | .transition() 868 | .duration(transition_duration / 2) 869 | .ease('ease-in') 870 | .style('opacity', 1); 871 | }) 872 | .on('mouseout', function () { 873 | if ( in_transition ) { return; } 874 | scope.hideTooltip(); 875 | }) 876 | .transition() 877 | .delay(function () { 878 | return (Math.cos(Math.PI * Math.random()) + 1) * transition_duration; 879 | }) 880 | .duration(function () { 881 | return transition_duration; 882 | }) 883 | .ease('ease-in') 884 | .style('opacity', 1) 885 | .call(function (transition, callback) { 886 | if ( transition.empty() ) { 887 | callback(); 888 | } 889 | var n = 0; 890 | transition 891 | .each(function() { ++n; }) 892 | .each('end', function() { 893 | if ( !--n ) { 894 | callback.apply(this, arguments); 895 | } 896 | }); 897 | }, function() { 898 | in_transition = false; 899 | }); 900 | 901 | // Add week labels 902 | labels.selectAll('.label-week').remove(); 903 | labels.selectAll('.label-week') 904 | .data(week_labels) 905 | .enter() 906 | .append('text') 907 | .attr('class', 'label label-week') 908 | .attr('font-size', function () { 909 | return Math.floor(label_padding / 3) + 'px'; 910 | }) 911 | .text(function (d) { 912 | return 'Week ' + d.week(); 913 | }) 914 | .attr('x', function (d) { 915 | return weekScale(d.week()); 916 | }) 917 | .attr('y', label_padding / 2) 918 | .on('mouseenter', function (weekday) { 919 | if ( in_transition ) { return; } 920 | 921 | items.selectAll('.item-block-month') 922 | .transition() 923 | .duration(transition_duration) 924 | .ease('ease-in') 925 | .style('opacity', function (d) { 926 | return ( moment(d.date).week() === weekday.week() ) ? 1 : 0.1; 927 | }); 928 | }) 929 | .on('mouseout', function () { 930 | if ( in_transition ) { return; } 931 | 932 | items.selectAll('.item-block-month') 933 | .transition() 934 | .duration(transition_duration) 935 | .ease('ease-in') 936 | .style('opacity', 1); 937 | }) 938 | .on('click', function (d) { 939 | if ( in_transition ) { return; } 940 | 941 | // Check week data 942 | var week_data = scope.data.filter(function (e) { 943 | return d.startOf('week') <= moment(e.date) && moment(e.date) < d.endOf('week'); 944 | }); 945 | 946 | // Don't transition if there is no data to show 947 | if ( !week_data.length ) { return; } 948 | 949 | in_transition = true; 950 | 951 | // Set selected month to the one clicked on 952 | scope.selected = { date: d }; 953 | 954 | // Hide tooltip 955 | scope.hideTooltip(); 956 | 957 | // Remove all year overview related items and labels 958 | scope.removeMonthOverview(); 959 | 960 | // Redraw the chart 961 | scope.overview = 'week'; 962 | scope.drawChart(); 963 | }); 964 | 965 | 966 | // Add day labels 967 | labels.selectAll('.label-day').remove(); 968 | labels.selectAll('.label-day') 969 | .data(day_labels) 970 | .enter() 971 | .append('text') 972 | .attr('class', 'label label-day') 973 | .attr('x', label_padding / 3) 974 | .attr('y', function (d, i) { 975 | return dayScale(i) + dayScale.rangeBand() / 1.75; 976 | }) 977 | .style('text-anchor', 'left') 978 | .attr('font-size', function () { 979 | return Math.floor(label_padding / 3) + 'px'; 980 | }) 981 | .text(function (d) { 982 | return moment(d).format('dddd')[0]; 983 | }) 984 | .on('mouseenter', function (d) { 985 | if ( in_transition ) { return; } 986 | 987 | var selected_day = moment(d); 988 | items.selectAll('.item-block-month') 989 | .transition() 990 | .duration(transition_duration) 991 | .ease('ease-in') 992 | .style('opacity', function (d) { 993 | return (moment(d.date).day() === selected_day.day()) ? 1 : 0.1; 994 | }); 995 | }) 996 | .on('mouseout', function () { 997 | if ( in_transition ) { return; } 998 | 999 | items.selectAll('.item-block-month') 1000 | .transition() 1001 | .duration(transition_duration) 1002 | .ease('ease-in') 1003 | .style('opacity', 1); 1004 | }); 1005 | 1006 | // Add button to switch back to previous overview 1007 | scope.drawButton(); 1008 | }; 1009 | 1010 | 1011 | /** 1012 | * Draw week overview 1013 | */ 1014 | scope.drawWeekOverview = function () { 1015 | // Add current overview to the history 1016 | if ( scope.history[scope.history.length-1] !== scope.overview ) { 1017 | scope.history.push(scope.overview); 1018 | } 1019 | 1020 | // Define beginning and end of the week 1021 | var start_of_week = moment(scope.selected.date).startOf('week'); 1022 | var end_of_week = moment(scope.selected.date).endOf('week'); 1023 | 1024 | // Filter data down to the selected week 1025 | var week_data = scope.data.filter(function (d) { 1026 | return start_of_week <= moment(d.date) && moment(d.date) < end_of_week; 1027 | }); 1028 | var max_value = d3.max(week_data, function (d) { 1029 | return d3.max(d.summary, function (d) { 1030 | return d.value; 1031 | }); 1032 | }); 1033 | 1034 | // Define day labels and axis 1035 | var day_labels = d3.time.days(moment().startOf('week'), moment().endOf('week')); 1036 | var dayScale = d3.scale.ordinal() 1037 | .rangeRoundBands([label_padding, height]) 1038 | .domain(day_labels.map(function (d) { 1039 | return moment(d).weekday(); 1040 | })); 1041 | 1042 | // Define week labels and axis 1043 | var week_labels = [start_of_week]; 1044 | var weekScale = d3.scale.ordinal() 1045 | .rangeRoundBands([label_padding, width], 0.01) 1046 | .domain(week_labels.map(function (weekday) { 1047 | return weekday.week(); 1048 | })); 1049 | 1050 | // Add week data items to the overview 1051 | items.selectAll('.item-block-week').remove(); 1052 | var item_block = items.selectAll('.item-block-week') 1053 | .data(week_data) 1054 | .enter() 1055 | .append('g') 1056 | .attr('class', 'item item-block-week') 1057 | .attr('width', function () { 1058 | return (width - label_padding) / week_labels.length - gutter * 5; 1059 | }) 1060 | .attr('height', function () { 1061 | return Math.min(dayScale.rangeBand(), max_block_height); 1062 | }) 1063 | .attr('transform', function (d) { 1064 | return 'translate(' + weekScale(moment(d.date).week()) + ',' + ((dayScale(moment(d.date).weekday()) + dayScale.rangeBand() / 1.75) - 15)+ ')'; 1065 | }) 1066 | .attr('total', function (d) { 1067 | return d.total; 1068 | }) 1069 | .attr('date', function (d) { 1070 | return d.date; 1071 | }) 1072 | .attr('offset', 0) 1073 | .on('click', function (d) { 1074 | if ( in_transition ) { return; } 1075 | 1076 | // Don't transition if there is no data to show 1077 | if ( d.total === 0 ) { return; } 1078 | 1079 | in_transition = true; 1080 | 1081 | // Set selected date to the one clicked on 1082 | scope.selected = d; 1083 | 1084 | // Hide tooltip 1085 | scope.hideTooltip(); 1086 | 1087 | // Remove all week overview related items and labels 1088 | scope.removeWeekOverview(); 1089 | 1090 | // Redraw the chart 1091 | scope.overview = 'day'; 1092 | scope.drawChart(); 1093 | }); 1094 | 1095 | var item_width = (width - label_padding) / week_labels.length - gutter * 5; 1096 | var itemScale = d3.scale.linear() 1097 | .rangeRound([0, item_width]); 1098 | 1099 | item_block.selectAll('.item-block-rect') 1100 | .data(function (d) { 1101 | return d.summary; 1102 | }) 1103 | .enter() 1104 | .append('rect') 1105 | .attr('class', 'item item-block-rect') 1106 | .attr('x', function (d) { 1107 | var total = parseInt(d3.select(this.parentNode).attr('total')); 1108 | var offset = parseInt(d3.select(this.parentNode).attr('offset')); 1109 | itemScale.domain([0, total]); 1110 | d3.select(this.parentNode).attr('offset', offset + itemScale(d.value)); 1111 | return offset; 1112 | }) 1113 | .attr('width', function (d) { 1114 | var total = parseInt(d3.select(this.parentNode).attr('total')); 1115 | itemScale.domain([0, total]); 1116 | return Math.max((itemScale(d.value) - item_gutter), 1) 1117 | }) 1118 | .attr('height', function () { 1119 | return Math.min(dayScale.rangeBand(), max_block_height); 1120 | }) 1121 | .attr('fill', function (d) { 1122 | var color = d3.scale.linear() 1123 | .range(['#ffffff', scope.color || '#ff4500']) 1124 | .domain([-0.15 * max_value, max_value]); 1125 | return color(d.value) || '#ff4500'; 1126 | }) 1127 | .style('opacity', 0) 1128 | .on('mouseover', function(d) { 1129 | if ( in_transition ) { return; } 1130 | 1131 | // Get date from the parent node 1132 | var date = new Date(d3.select(this.parentNode).attr('date')); 1133 | 1134 | // Construct tooltip 1135 | var tooltip_html = ''; 1136 | tooltip_html += '
' + d.name + '

'; 1137 | tooltip_html += '
' + (d.value ? scope.formatTime(d.value) : 'No time') + ' tracked
'; 1138 | tooltip_html += '
on ' + moment(date).format('dddd, MMM Do YYYY') + '
'; 1139 | 1140 | // Calculate tooltip position 1141 | var total = parseInt(d3.select(this.parentNode).attr('total')); 1142 | itemScale.domain([0, total]); 1143 | var x = parseInt(d3.select(this).attr('x')) + itemScale(d.value) / 4 + tooltip_width / 4; 1144 | while ( width - x < (tooltip_width + tooltip_padding * 3) ) { 1145 | x -= 10; 1146 | } 1147 | var y = dayScale(moment(date).weekday()) + tooltip_padding * 1.5; 1148 | 1149 | // Show tooltip 1150 | tooltip.html(tooltip_html) 1151 | .style('left', x + 'px') 1152 | .style('top', y + 'px') 1153 | .transition() 1154 | .duration(transition_duration / 2) 1155 | .ease('ease-in') 1156 | .style('opacity', 1); 1157 | }) 1158 | .on('mouseout', function () { 1159 | if ( in_transition ) { return; } 1160 | scope.hideTooltip(); 1161 | }) 1162 | .transition() 1163 | .delay(function () { 1164 | return (Math.cos(Math.PI * Math.random()) + 1) * transition_duration; 1165 | }) 1166 | .duration(function () { 1167 | return transition_duration; 1168 | }) 1169 | .ease('ease-in') 1170 | .style('opacity', 1) 1171 | .call(function (transition, callback) { 1172 | if ( transition.empty() ) { 1173 | callback(); 1174 | } 1175 | var n = 0; 1176 | transition 1177 | .each(function() { ++n; }) 1178 | .each('end', function() { 1179 | if ( !--n ) { 1180 | callback.apply(this, arguments); 1181 | } 1182 | }); 1183 | }, function() { 1184 | in_transition = false; 1185 | }); 1186 | 1187 | // Add week labels 1188 | labels.selectAll('.label-week').remove(); 1189 | labels.selectAll('.label-week') 1190 | .data(week_labels) 1191 | .enter() 1192 | .append('text') 1193 | .attr('class', 'label label-week') 1194 | .attr('font-size', function () { 1195 | return Math.floor(label_padding / 3) + 'px'; 1196 | }) 1197 | .text(function (d) { 1198 | return 'Week ' + d.week(); 1199 | }) 1200 | .attr('x', function (d) { 1201 | return weekScale(d.week()); 1202 | }) 1203 | .attr('y', label_padding / 2) 1204 | .on('mouseenter', function (weekday) { 1205 | if ( in_transition ) { return; } 1206 | 1207 | items.selectAll('.item-block-week') 1208 | .transition() 1209 | .duration(transition_duration) 1210 | .ease('ease-in') 1211 | .style('opacity', function (d) { 1212 | return ( moment(d.date).week() === weekday.week() ) ? 1 : 0.1; 1213 | }); 1214 | }) 1215 | .on('mouseout', function () { 1216 | if ( in_transition ) { return; } 1217 | 1218 | items.selectAll('.item-block-week') 1219 | .transition() 1220 | .duration(transition_duration) 1221 | .ease('ease-in') 1222 | .style('opacity', 1); 1223 | }); 1224 | 1225 | // Add day labels 1226 | labels.selectAll('.label-day').remove(); 1227 | labels.selectAll('.label-day') 1228 | .data(day_labels) 1229 | .enter() 1230 | .append('text') 1231 | .attr('class', 'label label-day') 1232 | .attr('x', label_padding / 3) 1233 | .attr('y', function (d, i) { 1234 | return dayScale(i) + dayScale.rangeBand() / 1.75; 1235 | }) 1236 | .style('text-anchor', 'left') 1237 | .attr('font-size', function () { 1238 | return Math.floor(label_padding / 3) + 'px'; 1239 | }) 1240 | .text(function (d) { 1241 | return moment(d).format('dddd')[0]; 1242 | }) 1243 | .on('mouseenter', function (d) { 1244 | if ( in_transition ) { return; } 1245 | 1246 | var selected_day = moment(d); 1247 | items.selectAll('.item-block-week') 1248 | .transition() 1249 | .duration(transition_duration) 1250 | .ease('ease-in') 1251 | .style('opacity', function (d) { 1252 | return (moment(d.date).day() === selected_day.day()) ? 1 : 0.1; 1253 | }); 1254 | }) 1255 | .on('mouseout', function () { 1256 | if ( in_transition ) { return; } 1257 | 1258 | items.selectAll('.item-block-week') 1259 | .transition() 1260 | .duration(transition_duration) 1261 | .ease('ease-in') 1262 | .style('opacity', 1); 1263 | }); 1264 | 1265 | // Add button to switch back to previous overview 1266 | scope.drawButton(); 1267 | }; 1268 | 1269 | 1270 | /** 1271 | * Draw day overview 1272 | */ 1273 | scope.drawDayOverview = function () { 1274 | // Add current overview to the history 1275 | if ( scope.history[scope.history.length-1] !== scope.overview ) { 1276 | scope.history.push(scope.overview); 1277 | } 1278 | 1279 | // Initialize selected date to today if it was not set 1280 | if ( !Object.keys(scope.selected).length ) { 1281 | scope.selected = scope.data[scope.data.length - 1]; 1282 | } 1283 | 1284 | var project_labels = scope.selected.summary.map(function (project) { 1285 | return project.name; 1286 | }); 1287 | var projectScale = d3.scale.ordinal() 1288 | .rangeRoundBands([label_padding, height]) 1289 | .domain(project_labels); 1290 | 1291 | var itemScale = d3.time.scale() 1292 | .range([label_padding*2, width]) 1293 | .domain([moment(scope.selected.date).startOf('day'), moment(scope.selected.date).endOf('day')]); 1294 | items.selectAll('.item-block').remove(); 1295 | items.selectAll('.item-block') 1296 | .data(scope.selected.details) 1297 | .enter() 1298 | .append('rect') 1299 | .attr('class', 'item item-block') 1300 | .attr('x', function (d) { 1301 | return itemScale(moment(d.date)); 1302 | }) 1303 | .attr('y', function (d) { 1304 | return (projectScale(d.name) + projectScale.rangeBand() / 2) - 15; 1305 | }) 1306 | .attr('width', function (d) { 1307 | var end = itemScale(d3.time.second.offset(moment(d.date), d.value)); 1308 | return Math.max((end - itemScale(moment(d.date))), 1); 1309 | }) 1310 | .attr('height', function () { 1311 | return Math.min(projectScale.rangeBand(), max_block_height); 1312 | }) 1313 | .attr('fill', function () { 1314 | return scope.color || '#ff4500'; 1315 | }) 1316 | .style('opacity', 0) 1317 | .on('mouseover', function(d) { 1318 | if ( in_transition ) { return; } 1319 | 1320 | // Construct tooltip 1321 | var tooltip_html = ''; 1322 | tooltip_html += '
' + d.name + '

'; 1323 | tooltip_html += '
' + (d.value ? scope.formatTime(d.value) : 'No time') + ' tracked
'; 1324 | tooltip_html += '
on ' + moment(d.date).format('dddd, MMM Do YYYY HH:mm') + '
'; 1325 | 1326 | // Calculate tooltip position 1327 | var x = d.value * 100 / (60 * 60 * 24) + itemScale(moment(d.date)); 1328 | while ( width - x < (tooltip_width + tooltip_padding * 3) ) { 1329 | x -= 10; 1330 | } 1331 | var y = projectScale(d.name) + projectScale.rangeBand() / 2 + tooltip_padding / 2; 1332 | 1333 | // Show tooltip 1334 | tooltip.html(tooltip_html) 1335 | .style('left', x + 'px') 1336 | .style('top', y + 'px') 1337 | .transition() 1338 | .duration(transition_duration / 2) 1339 | .ease('ease-in') 1340 | .style('opacity', 1); 1341 | }) 1342 | .on('mouseout', function () { 1343 | if ( in_transition ) { return; } 1344 | scope.hideTooltip(); 1345 | }) 1346 | .on('click', function (d) { 1347 | if ( scope.handler ) { 1348 | scope.handler(d); 1349 | } 1350 | }) 1351 | .transition() 1352 | .delay(function () { 1353 | return (Math.cos(Math.PI * Math.random()) + 1) * transition_duration; 1354 | }) 1355 | .duration(function () { 1356 | return transition_duration; 1357 | }) 1358 | .ease('ease-in') 1359 | .style('opacity', 0.5) 1360 | .call(function (transition, callback) { 1361 | if ( transition.empty() ) { 1362 | callback(); 1363 | } 1364 | var n = 0; 1365 | transition 1366 | .each(function() { ++n; }) 1367 | .each('end', function() { 1368 | if ( !--n ) { 1369 | callback.apply(this, arguments); 1370 | } 1371 | }); 1372 | }, function() { 1373 | in_transition = false; 1374 | }); 1375 | 1376 | // Add time labels 1377 | var timeLabels = d3.time.hours(moment(scope.selected.date).startOf('day'), moment(scope.selected.date).endOf('day')); 1378 | var timeScale = d3.time.scale() 1379 | .range([label_padding*2, width]) 1380 | .domain([0, timeLabels.length]); 1381 | labels.selectAll('.label-time').remove(); 1382 | labels.selectAll('.label-time') 1383 | .data(timeLabels) 1384 | .enter() 1385 | .append('text') 1386 | .attr('class', 'label label-time') 1387 | .attr('font-size', function () { 1388 | return Math.floor(label_padding / 3) + 'px'; 1389 | }) 1390 | .text(function (d) { 1391 | return moment(d).format('HH:mm'); 1392 | }) 1393 | .attr('x', function (d, i) { 1394 | return timeScale(i); 1395 | }) 1396 | .attr('y', label_padding / 2) 1397 | .on('mouseenter', function (d) { 1398 | if ( in_transition ) { return; } 1399 | 1400 | var selected = itemScale(moment(d)); 1401 | items.selectAll('.item-block') 1402 | .transition() 1403 | .duration(transition_duration) 1404 | .ease('ease-in') 1405 | .style('opacity', function (d) { 1406 | var start = itemScale(moment(d.date)); 1407 | var end = itemScale(moment(d.date).add(d.value, 'seconds')); 1408 | return ( selected >= start && selected <= end ) ? 1 : 0.1; 1409 | }); 1410 | }) 1411 | .on('mouseout', function () { 1412 | if ( in_transition ) { return; } 1413 | 1414 | items.selectAll('.item-block') 1415 | .transition() 1416 | .duration(transition_duration) 1417 | .ease('ease-in') 1418 | .style('opacity', 0.5); 1419 | }); 1420 | 1421 | // Add project labels 1422 | labels.selectAll('.label-project').remove(); 1423 | labels.selectAll('.label-project') 1424 | .data(project_labels) 1425 | .enter() 1426 | .append('text') 1427 | .attr('class', 'label label-project') 1428 | .attr('x', gutter) 1429 | .attr('y', function (d) { 1430 | return projectScale(d) + projectScale.rangeBand() / 2; 1431 | }) 1432 | .attr('min-height', function () { 1433 | return projectScale.rangeBand(); 1434 | }) 1435 | .style('text-anchor', 'left') 1436 | .attr('font-size', function () { 1437 | return Math.floor(label_padding / 3) + 'px'; 1438 | }) 1439 | .text(function (d) { 1440 | return d; 1441 | }) 1442 | .each(function () { 1443 | var obj = d3.select(this), 1444 | text_length = obj.node().getComputedTextLength(), 1445 | text = obj.text(); 1446 | while (text_length > (label_padding * 1.5) && text.length > 0) { 1447 | text = text.slice(0, -1); 1448 | obj.text(text + '...'); 1449 | text_length = obj.node().getComputedTextLength(); 1450 | } 1451 | }) 1452 | .on('mouseenter', function (project) { 1453 | if ( in_transition ) { return; } 1454 | 1455 | items.selectAll('.item-block') 1456 | .transition() 1457 | .duration(transition_duration) 1458 | .ease('ease-in') 1459 | .style('opacity', function (d) { 1460 | return (d.name === project) ? 1 : 0.1; 1461 | }); 1462 | }) 1463 | .on('mouseout', function () { 1464 | if ( in_transition ) { return; } 1465 | 1466 | items.selectAll('.item-block') 1467 | .transition() 1468 | .duration(transition_duration) 1469 | .ease('ease-in') 1470 | .style('opacity', 0.5); 1471 | }); 1472 | 1473 | // Add button to switch back to previous overview 1474 | scope.drawButton(); 1475 | }; 1476 | 1477 | 1478 | /** 1479 | * Draw the button for navigation purposes 1480 | */ 1481 | scope.drawButton = function () { 1482 | buttons.selectAll('.button').remove(); 1483 | var button = buttons.append('g') 1484 | .attr('class', 'button button-back') 1485 | .style('opacity', 0) 1486 | .on('click', function () { 1487 | if ( in_transition ) { return; } 1488 | 1489 | // Set transition boolean 1490 | in_transition = true; 1491 | 1492 | // Clean the canvas from whichever overview type was on 1493 | if ( scope.overview === 'year' ) { 1494 | scope.removeYearOverview(); 1495 | } else if ( scope.overview === 'month' ) { 1496 | scope.removeMonthOverview(); 1497 | } else if ( scope.overview === 'week' ) { 1498 | scope.removeWeekOverview(); 1499 | } else if ( scope.overview === 'day' ) { 1500 | scope.removeDayOverview(); 1501 | } 1502 | 1503 | // Redraw the chart 1504 | scope.history.pop(); 1505 | scope.overview = scope.history.pop(); 1506 | scope.drawChart(); 1507 | }); 1508 | button.append('circle') 1509 | .attr('cx', label_padding / 2.25) 1510 | .attr('cy', label_padding / 2.5) 1511 | .attr('r', item_size / 2); 1512 | button.append('text') 1513 | .attr('x', label_padding / 2.25) 1514 | .attr('y', label_padding / 2.5) 1515 | .attr('dy', function () { 1516 | return Math.floor(width / 100) / 3; 1517 | }) 1518 | .attr('font-size', function () { 1519 | return Math.floor(label_padding / 3) + 'px'; 1520 | }) 1521 | .html('←'); 1522 | button.transition() 1523 | .duration(transition_duration) 1524 | .ease('ease-in') 1525 | .style('opacity', 1); 1526 | }; 1527 | 1528 | 1529 | /** 1530 | * Transition and remove items and labels related to global overview 1531 | */ 1532 | scope.removeGlobalOverview = function () { 1533 | items.selectAll('.item-block-year') 1534 | .transition() 1535 | .duration(transition_duration) 1536 | .ease('ease-out') 1537 | .style('opacity', 0) 1538 | .remove(); 1539 | labels.selectAll('.label-year').remove(); 1540 | }, 1541 | 1542 | 1543 | /** 1544 | * Transition and remove items and labels related to year overview 1545 | */ 1546 | scope.removeYearOverview = function () { 1547 | items.selectAll('.item-circle') 1548 | .transition() 1549 | .duration(transition_duration) 1550 | .ease('ease') 1551 | .style('opacity', 0) 1552 | .remove(); 1553 | labels.selectAll('.label-day').remove(); 1554 | labels.selectAll('.label-month').remove(); 1555 | scope.hideBackButton(); 1556 | }; 1557 | 1558 | 1559 | /** 1560 | * Transition and remove items and labels related to month overview 1561 | */ 1562 | scope.removeMonthOverview = function () { 1563 | items.selectAll('.item-block-month').selectAll('.item-block-rect') 1564 | .transition() 1565 | .duration(transition_duration) 1566 | .ease('ease-in') 1567 | .style('opacity', 0) 1568 | .attr('x', function (d, i) { 1569 | return ( i % 2 === 0) ? -width/3 : width/3; 1570 | }) 1571 | .remove(); 1572 | labels.selectAll('.label-day').remove(); 1573 | labels.selectAll('.label-week').remove(); 1574 | scope.hideBackButton(); 1575 | }; 1576 | 1577 | 1578 | /** 1579 | * Transition and remove items and labels related to week overview 1580 | */ 1581 | scope.removeWeekOverview = function () { 1582 | items.selectAll('.item-block-week').selectAll('.item-block-rect') 1583 | .transition() 1584 | .duration(transition_duration) 1585 | .ease('ease-in') 1586 | .style('opacity', 0) 1587 | .attr('x', function (d, i) { 1588 | return ( i % 2 === 0) ? -width/3 : width/3; 1589 | }) 1590 | .remove(); 1591 | labels.selectAll('.label-day').remove(); 1592 | labels.selectAll('.label-week').remove(); 1593 | scope.hideBackButton(); 1594 | }; 1595 | 1596 | 1597 | /** 1598 | * Transition and remove items and labels related to daily overview 1599 | */ 1600 | scope.removeDayOverview = function () { 1601 | items.selectAll('.item-block') 1602 | .transition() 1603 | .duration(transition_duration) 1604 | .ease('ease-in') 1605 | .style('opacity', 0) 1606 | .attr('x', function (d, i) { 1607 | return ( i % 2 === 0) ? -width/3 : width/3; 1608 | }) 1609 | .remove(); 1610 | labels.selectAll('.label-time').remove(); 1611 | labels.selectAll('.label-project').remove(); 1612 | scope.hideBackButton(); 1613 | }; 1614 | 1615 | 1616 | /** 1617 | * Helper function to hide the tooltip 1618 | */ 1619 | scope.hideTooltip = function () { 1620 | tooltip.transition() 1621 | .duration(transition_duration / 2) 1622 | .ease('ease-in') 1623 | .style('opacity', 0); 1624 | }; 1625 | 1626 | 1627 | /** 1628 | * Helper function to hide the back button 1629 | */ 1630 | scope.hideBackButton = function () { 1631 | buttons.selectAll('.button') 1632 | .transition() 1633 | .duration(transition_duration) 1634 | .ease('ease') 1635 | .style('opacity', 0) 1636 | .remove(); 1637 | }; 1638 | 1639 | 1640 | /** 1641 | * Helper function to convert seconds to a human readable format 1642 | * @param seconds Integer 1643 | */ 1644 | scope.formatTime = function (seconds) { 1645 | var hours = Math.floor(seconds / 3600); 1646 | var minutes = Math.floor((seconds - (hours * 3600)) / 60); 1647 | var time = ''; 1648 | if ( hours > 0 ) { 1649 | time += hours === 1 ? '1 hour ' : hours + ' hours '; 1650 | } 1651 | if ( minutes > 0 ) { 1652 | time += minutes === 1 ? '1 minute' : minutes + ' minutes'; 1653 | } 1654 | if ( hours === 0 && minutes === 0 ) { 1655 | time = Math.round(seconds) + ' seconds'; 1656 | } 1657 | return time; 1658 | }; 1659 | } 1660 | }; 1661 | }]); 1662 | --------------------------------------------------------------------------------