UNPKG

56 kBPlain TextView Raw
1import { Component, Input, Output, EventEmitter, ViewChild, HostListener } from '@angular/core';
2
3// Import dependencies
4import moment from 'moment';
5import * as d3 from 'd3';
6
7@Component({
8 selector: 'calendar-heatmap',
9 template: `<div #root></div>`,
10 styles: [`
11 :host {
12 user-select: none;
13 -ms-user-select: none;
14 -moz-user-select: none;
15 -webkit-user-select: none;
16 }
17 :host >>> .item {
18 cursor: pointer;
19 }
20 :host >>> .label {
21 cursor: pointer;
22 fill: rgb(170, 170, 170);
23 font-family: Helvetica, arial, 'Open Sans', sans-serif;
24 }
25 :host >>> .button {
26 cursor: pointer;
27 fill: transparent;
28 stroke-width: 2;
29 stroke: rgb(170, 170, 170);
30 }
31 :host >>> .button text {
32 stroke-width: 1;
33 text-anchor: middle;
34 fill: rgb(170, 170, 170);
35 }
36 :host >>> .heatmap-tooltip {
37 pointer-events: none;
38 position: absolute;
39 z-index: 9999;
40 width: 250px;
41 max-width: 250px;
42 overflow: hidden;
43 padding: 15px;
44 font-size: 12px;
45 line-height: 14px;
46 color: rgb(51, 51, 51);
47 font-family: Helvetica, arial, 'Open Sans', sans-serif;
48 background: rgba(255, 255, 255, 0.75);
49 }
50 :host >>> .heatmap-tooltip .header strong {
51 display: inline-block;
52 width: 250px;
53 }
54 :host >>> .heatmap-tooltip span {
55 display: inline-block;
56 width: 50%;
57 padding-right: 10px;
58 box-sizing: border-box;
59 }
60 :host >>> .heatmap-tooltip span,
61 :host >>> .heatmap-tooltip .header strong {
62 white-space: nowrap;
63 overflow: hidden;
64 text-overflow: ellipsis;
65 }
66 `],
67})
68export class CalendarHeatmap {
69 @ViewChild('root') element: any;
70
71 @Input() data: Array<object>;
72 @Input() color: string = '#ff4500';
73 @Input() overview: string = 'global';
74
75 @Output() handler: EventEmitter<object> = new EventEmitter<object>();
76
77 // Defaults
78 private gutter: number = 5;
79 private item_gutter: number = 1;
80 private width: number = 1000;
81 private height: number = 200;
82 private item_size: number = 10;
83 private label_padding: number = 40;
84 private max_block_height: number = 20;
85 private transition_duration: number = 500;
86 private in_transition: boolean = false;
87
88 // Tooltip defaults
89 private tooltip_width: number = 250;
90 private tooltip_padding: number = 15;
91
92 // Overview defaults
93 private history = ['global'];
94 private selected = {};
95
96 // D3 related variables
97 private svg: any;
98 private items: any;
99 private labels: any;
100 private buttons: any;
101 private tooltip: any;
102
103
104 /**
105 * Check if data is available
106 */
107 ngOnChanges() {
108 if ( !this.data ) { return; }
109
110 // Update data summaries
111 this.updateDataSummary();
112
113 // Draw the chart
114 this.drawChart();
115 };
116
117
118 /**
119 * Get hold of the root element and append our svg
120 */
121 ngAfterViewInit() {
122 var element = this.element.nativeElement;
123
124 // Initialize svg element
125 this.svg = d3.select(element)
126 .append('svg')
127 .attr('class', 'svg');
128
129 // Initialize main svg elements
130 this.items = this.svg.append('g');
131 this.labels = this.svg.append('g');
132 this.buttons = this.svg.append('g');
133
134 // Add tooltip to the same element as main svg
135 this.tooltip = d3.select(element).append('div')
136 .attr('class', 'heatmap-tooltip')
137 .style('opacity', 0);
138
139 // Calculate chart dimensions
140 this.calculateDimensions();
141
142 // Draw the chart
143 this.drawChart();
144 };
145
146
147 /**
148 * Utility function to get number of complete weeks in a year
149 */
150 getNumberOfWeeks() {
151 var dayIndex = Math.round((+moment() - +moment().subtract(1, 'year').startOf('week')) / 86400000);
152 var colIndex = Math.trunc(dayIndex / 7);
153 var numWeeks = colIndex + 1;
154 return numWeeks;
155 };
156
157
158 /**
159 * Utility funciton to calculate chart dimensions
160 */
161 calculateDimensions() {
162 var element = this.element.nativeElement;
163 this.width = element.clientWidth < 1000 ? 1000 : element.clientWidth;
164 this.item_size = ((this.width - this.label_padding) / this.getNumberOfWeeks() - this.gutter);
165 this.height = this.label_padding + 7 * (this.item_size + this.gutter);
166 this.svg.attr('width', this.width).attr('height', this.height);
167 };
168
169
170 /**
171 * Recalculate dimensions on window resize events
172 */
173 @HostListener('window:resize', ['$event'])
174 onResize(event: any) {
175 this.calculateDimensions();
176 if ( !!this.data && !!this.data[0]['summary'] ) {
177 this.drawChart();
178 }
179 };
180
181
182 /**
183 * Helper function to check for data summary
184 */
185 updateDataSummary() {
186 // Get daily summary if that was not provided
187 if ( !this.data[0]['summary'] ) {
188 this.data.map((d) => {
189 var summary = d['details'].reduce( (uniques: any, project: any) => {
190 if ( !uniques[project.name] ) {
191 uniques[project.name] = {
192 'value': project.value
193 };
194 } else {
195 uniques[project.name].value += project.value;
196 }
197 return uniques;
198 }, {});
199 var unsorted_summary = Object.keys(summary).map((key) => {
200 return {
201 'name': key,
202 'value': summary[key].value
203 };
204 });
205 d['summary'] = unsorted_summary.sort((a, b) => {
206 return b.value - a.value;
207 });
208 return d;
209 });
210 }
211 }
212
213
214 /**
215 * Draw the chart based on the current overview type
216 */
217 drawChart() {
218 if ( !this.svg || !this.data ) { return; }
219
220 if ( this.overview === 'global' ) {
221 this.drawGlobalOverview();
222 } else if ( this.overview === 'year' ) {
223 this.drawYearOverview();
224 } else if ( this.overview === 'month' ) {
225 this.drawMonthOverview();
226 } else if ( this.overview === 'week' ) {
227 this.drawWeekOverview();
228 } else if ( this.overview === 'day' ) {
229 this.drawDayOverview();
230 }
231 };
232
233
234 /**
235 * Draw global overview (multiple years)
236 */
237 drawGlobalOverview() {
238 // Add current overview to the history
239 if ( this.history[this.history.length-1] !== this.overview ) {
240 this.history.push(this.overview);
241 }
242
243 // Define start and end of the dataset
244 var start: any = moment(this.data[0]['date']).startOf('year');
245 var end: any = moment(this.data[this.data.length-1]['date']).endOf('year');
246
247 // Define array of years and total values
248 var data = this.data;
249 var year_data = d3.timeYears(start, end).map((d: any) => {
250 var date = moment(d);
251 return {
252 'date': date,
253 'total': data.reduce((prev: number, current: any) => {
254 if ( moment(current.date).year() === date.year() ) {
255 prev += current.total;
256 }
257 return prev;
258 }, 0),
259 'summary': function () {
260 var summary = data.reduce((summary: any, d: any) => {
261 if ( moment(d.date).year() === date.year() ) {
262 for ( var i = 0; i < d.summary.length; i++ ) {
263 if ( !summary[d.summary[i].name] ) {
264 summary[d.summary[i].name] = {
265 'value': d.summary[i].value,
266 };
267 } else {
268 summary[d.summary[i].name].value += d.summary[i].value;
269 }
270 }
271 }
272 return summary;
273 }, {});
274 var unsorted_summary = Object.keys(summary).map((key) => {
275 return {
276 'name': key,
277 'value': summary[key].value
278 };
279 });
280 return unsorted_summary.sort((a, b) => {
281 return b.value - a.value;
282 });
283 }(),
284 };
285 });
286
287 // Calculate max value of all the years in the dataset
288 var max_value = d3.max(year_data, (d: any) => {
289 return d.total;
290 });
291
292 // Define year labels and axis
293 var year_labels = d3.timeYears(start, end).map((d: any) => {
294 return moment(d);
295 });
296 var yearScale = d3.scaleBand()
297 .rangeRound([0, this.width])
298 .padding(0.05)
299 .domain(year_labels.map((d: any) => {
300 return d.year();
301 }));
302
303 // Add month data items to the overview
304 this.items.selectAll('.item-block-year').remove();
305 var item_block = this.items.selectAll('.item-block-year')
306 .data(year_data)
307 .enter()
308 .append('rect')
309 .attr('class', 'item item-block-year')
310 .attr('width', () => {
311 return (this.width - this.label_padding) / year_labels.length - this.gutter * 5;
312 })
313 .attr('height', () => {
314 return this.height - this.label_padding;
315 })
316 .attr('transform', (d: any) => {
317 return 'translate(' + yearScale(d.date.year()) + ',' + this.tooltip_padding * 2 + ')';
318 })
319 .attr('fill', (d: any) => {
320 var color = d3.scaleLinear<string>()
321 .range(['#ffffff', this.color || '#ff4500'])
322 .domain([-0.15 * max_value, max_value]);
323 return color(d.total) || '#ff4500';
324 })
325 .on('click', (d: any) => {
326 if ( this.in_transition ) { return; }
327
328 // Set in_transition flag
329 this.in_transition = true;
330
331 // Set selected date to the one clicked on
332 this.selected = d;
333
334 // Hide tooltip
335 this.hideTooltip();
336
337 // Remove all month overview related items and labels
338 this.removeGlobalOverview();
339
340 // Redraw the chart
341 this.overview = 'year';
342 this.drawChart();
343 })
344 .style('opacity', 0)
345 .on('mouseover', (d: any) => {
346 if ( this.in_transition ) { return; }
347
348 // Construct tooltip
349 var tooltip_html = '';
350 tooltip_html += '<div><span><strong>Total time tracked:</strong></span>';
351
352 var sec = parseInt(d.total, 10);
353 var days = Math.floor(sec / 86400);
354 if ( days > 0 ) {
355 tooltip_html += '<span>' + (days === 1 ? '1 day' : days + ' days') + '</span></div>';
356 }
357 var hours = Math.floor((sec - (days * 86400)) / 3600);
358 if ( hours > 0 ) {
359 if ( days > 0 ) {
360 tooltip_html += '<div><span></span><span>' + (hours === 1 ? '1 hour' : hours + ' hours') + '</span></div>';
361 } else {
362 tooltip_html += '<span>' + (hours === 1 ? '1 hour' : hours + ' hours') + '</span></div>';
363 }
364 }
365 var minutes = Math.floor((sec - (days * 86400) - (hours * 3600)) / 60);
366 if ( minutes > 0 ) {
367 if ( days > 0 || hours > 0 ) {
368 tooltip_html += '<div><span></span><span>' + (minutes === 1 ? '1 minute' : minutes + ' minutes') + '</span></div>';
369 } else {
370 tooltip_html += '<span>' + (minutes === 1 ? '1 minute' : minutes + ' minutes') + '</span></div>';
371 }
372 }
373 tooltip_html += '<br />';
374
375 // Add summary to the tooltip
376 if ( d.summary.length <= 5 ) {
377 for ( var i = 0; i < d.summary.length; i++ ) {
378 tooltip_html += '<div><span><strong>' + d.summary[i].name + '</strong></span>';
379 tooltip_html += '<span>' + this.formatTime(d.summary[i].value) + '</span></div>';
380 };
381 } else {
382 for ( var i = 0; i < 5; i++ ) {
383 tooltip_html += '<div><span><strong>' + d.summary[i].name + '</strong></span>';
384 tooltip_html += '<span>' + this.formatTime(d.summary[i].value) + '</span></div>';
385 };
386 tooltip_html += '<br />';
387
388 var other_projects_sum = 0;
389 for ( var i = 5; i < d.summary.length; i++ ) {
390 other_projects_sum =+ d.summary[i].value;
391 };
392 tooltip_html += '<div><span><strong>Other:</strong></span>';
393 tooltip_html += '<span>' + this.formatTime(other_projects_sum) + '</span></div>';
394 }
395
396 // Calculate tooltip position
397 var x = yearScale(d.date.year()) + this.tooltip_padding * 2;
398 while ( this.width - x < (this.tooltip_width + this.tooltip_padding * 5) ) {
399 x -= 10;
400 }
401 var y = this.tooltip_padding * 3;
402
403 // Show tooltip
404 this.tooltip.html(tooltip_html)
405 .style('left', x + 'px')
406 .style('top', y + 'px')
407 .transition()
408 .duration(this.transition_duration / 2)
409 .ease(d3.easeLinear)
410 .style('opacity', 1);
411 })
412 .on('mouseout', () => {
413 if ( this.in_transition ) { return; }
414 this.hideTooltip();
415 })
416 .transition()
417 .delay((d: any, i: number) => {
418 return this.transition_duration * (i + 1) / 10;
419 })
420 .duration(() => {
421 return this.transition_duration;
422 })
423 .ease(d3.easeLinear)
424 .style('opacity', 1)
425 .call((transition: any, callback: any) => {
426 if ( transition.empty() ) {
427 callback();
428 }
429 var n = 0;
430 transition
431 .each(() => { ++n; })
432 .on('end', function() {
433 if ( !--n ) {
434 callback.apply(this, arguments);
435 }
436 });
437 }, () => {
438 this.in_transition = false;
439 });
440
441 // Add year labels
442 this.labels.selectAll('.label-year').remove();
443 this.labels.selectAll('.label-year')
444 .data(year_labels)
445 .enter()
446 .append('text')
447 .attr('class', 'label label-year')
448 .attr('font-size', () => {
449 return Math.floor(this.label_padding / 3) + 'px';
450 })
451 .text((d: any) => {
452 return d.year();
453 })
454 .attr('x', (d: any) => {
455 return yearScale(d.year());
456 })
457 .attr('y', this.label_padding / 2)
458 .on('mouseenter', (year_label: any) => {
459 if ( this.in_transition ) { return; }
460
461 this.items.selectAll('.item-block-year')
462 .transition()
463 .duration(this.transition_duration)
464 .ease(d3.easeLinear)
465 .style('opacity', (d: any) => {
466 return ( moment(d.date).year() === year_label.year() ) ? 1 : 0.1;
467 });
468 })
469 .on('mouseout', () => {
470 if ( this.in_transition ) { return; }
471
472 this.items.selectAll('.item-block-year')
473 .transition()
474 .duration(this.transition_duration)
475 .ease(d3.easeLinear)
476 .style('opacity', 1);
477 })
478 .on('click', (d: any) => {
479 if ( this.in_transition ) { return; }
480
481 // Set in_transition flag
482 this.in_transition = true;
483
484 // Set selected month to the one clicked on
485 this.selected = d;
486
487 // Hide tooltip
488 this.hideTooltip();
489
490 // Remove all year overview related items and labels
491 this.removeGlobalOverview();
492
493 // Redraw the chart
494 this.overview = 'year';
495 this.drawChart();
496 });
497 };
498
499
500 /**
501 * Draw year overview
502 */
503 drawYearOverview() {
504 // Add current overview to the history
505 if ( this.history[this.history.length-1] !== this.overview ) {
506 this.history.push(this.overview);
507 }
508
509 // Define start and end date of the selected year
510 var start_of_year = moment(this.selected['date']).startOf('year');
511 var end_of_year = moment(this.selected['date']).endOf('year');
512
513 // Filter data down to the selected year
514 var year_data = this.data.filter((d: any) => {
515 return start_of_year <= moment(d.date) && moment(d.date) < end_of_year;
516 });
517
518 // Calculate max value of the year data
519 var max_value = d3.max(year_data, (d: any) => {
520 return d.total;
521 });
522
523 var color = d3.scaleLinear<string>()
524 .range(['#ffffff', this.color])
525 .domain([-0.15 * max_value, max_value]);
526
527 this.items.selectAll('.item-circle').remove();
528 this.items.selectAll('.item-circle')
529 .data(year_data)
530 .enter()
531 .append('rect')
532 .attr('class', 'item item-circle')
533 .style('opacity', 0)
534 .attr('x', (d: any) => {
535 return this.calcItemX(d, start_of_year) + (this.item_size - this.calcItemSize(d, max_value)) / 2;
536 })
537 .attr('y', (d: any) => {
538 return this.calcItemY(d) + (this.item_size - this.calcItemSize(d, max_value)) / 2;
539 })
540 .attr('rx', (d: any) => {
541 return this.calcItemSize(d, max_value);
542 })
543 .attr('ry', (d: any) => {
544 return this.calcItemSize(d, max_value);
545 })
546 .attr('width', (d: any) => {
547 return this.calcItemSize(d, max_value);
548 })
549 .attr('height', (d: any) => {
550 return this.calcItemSize(d, max_value);
551 })
552 .attr('fill', (d: any) => {
553 return ( d.total > 0 ) ? color(d.total) : 'transparent';
554 })
555 .on('click', (d: any) => {
556 if ( this.in_transition ) { return; }
557
558 // Don't transition if there is no data to show
559 if ( d.total === 0 ) { return; }
560
561 this.in_transition = true;
562
563 // Set selected date to the one clicked on
564 this.selected = d;
565
566 // Hide tooltip
567 this.hideTooltip();
568
569 // Remove all year overview related items and labels
570 this.removeYearOverview();
571
572 // Redraw the chart
573 this.overview = 'day';
574 this.drawChart();
575 })
576 .on('mouseover', (d: any) => {
577 if ( this.in_transition ) { return; }
578
579 // Pulsating animation
580 var circle = d3.select(d3.event.currentTarget);
581 var repeat = () => {
582 circle.transition()
583 .duration(this.transition_duration)
584 .ease(d3.easeLinear)
585 .attr('x', (d: any) => {
586 return this.calcItemX(d, start_of_year) - (this.item_size * 1.1 - this.item_size) / 2;
587 })
588 .attr('y', (d: any) => {
589 return this.calcItemY(d) - (this.item_size * 1.1 - this.item_size) / 2;
590 })
591 .attr('width', this.item_size * 1.1)
592 .attr('height', this.item_size * 1.1)
593 .transition()
594 .duration(this.transition_duration)
595 .ease(d3.easeLinear)
596 .attr('x', (d: any) => {
597 return this.calcItemX(d, start_of_year) + (this.item_size - this.calcItemSize(d, max_value)) / 2;
598 })
599 .attr('y', (d: any) => {
600 return this.calcItemY(d) + (this.item_size - this.calcItemSize(d, max_value)) / 2;
601 })
602 .attr('width', (d: any) => {
603 return this.calcItemSize(d, max_value);
604 })
605 .attr('height', (d: any) => {
606 return this.calcItemSize(d, max_value);
607 })
608 .on('end', repeat);
609 };
610 repeat();
611
612 // Construct tooltip
613 var tooltip_html = '';
614 tooltip_html += '<div class="header"><strong>' + (d.total ? this.formatTime(d.total) : 'No time') + ' tracked</strong></div>';
615 tooltip_html += '<div>on ' + moment(d.date).format('dddd, MMM Do YYYY') + '</div><br>';
616
617 // Add summary to the tooltip
618 d.summary.map((d: any) => {
619 tooltip_html += '<div><span><strong>' + d.name + '</strong></span>';
620 tooltip_html += '<span>' + this.formatTime(d.value) + '</span></div>';
621 });
622
623 // Calculate tooltip position
624 var x = this.calcItemX(d, start_of_year) + this.item_size;
625 if ( this.width - x < (this.tooltip_width + this.tooltip_padding * 3) ) {
626 x -= this.tooltip_width + this.tooltip_padding * 2;
627 }
628 var y = this.calcItemY(d) + this.item_size;
629
630 // Show tooltip
631 this.tooltip.html(tooltip_html)
632 .style('left', x + 'px')
633 .style('top', y + 'px')
634 .transition()
635 .duration(this.transition_duration / 2)
636 .ease(d3.easeLinear)
637 .style('opacity', 1);
638 })
639 .on('mouseout', () => {
640 if ( this.in_transition ) { return; }
641
642 // Set circle radius back to what it's supposed to be
643 d3.select(d3.event.currentTarget).transition()
644 .duration(this.transition_duration / 2)
645 .ease(d3.easeLinear)
646 .attr('x', (d: any) => {
647 return this.calcItemX(d, start_of_year) + (this.item_size - this.calcItemSize(d, max_value)) / 2;
648 })
649 .attr('y', (d: any) => {
650 return this.calcItemY(d) + (this.item_size - this.calcItemSize(d, max_value)) / 2;
651 })
652 .attr('width', (d: any) => {
653 return this.calcItemSize(d, max_value);
654 })
655 .attr('height', (d: any) => {
656 return this.calcItemSize(d, max_value);
657 });
658
659 // Hide tooltip
660 this.hideTooltip();
661 })
662 .transition()
663 .delay(() => {
664 return (Math.cos(Math.PI * Math.random()) + 1) * this.transition_duration;
665 })
666 .duration(() => {
667 return this.transition_duration;
668 })
669 .ease(d3.easeLinear)
670 .style('opacity', 1)
671 .call((transition: any, callback: any) => {
672 if ( transition.empty() ) {
673 callback();
674 }
675 var n = 0;
676 transition
677 .each(() => { ++n; })
678 .on('end', function () {
679 if ( !--n ) {
680 callback.apply(this, arguments);
681 }
682 });
683 }, () => {
684 this.in_transition = false;
685 });
686
687 // Add month labels
688 var month_labels = d3.timeMonths(start_of_year.toDate(), end_of_year.toDate());
689 var monthScale = d3.scaleLinear()
690 .range([0, this.width])
691 .domain([0, month_labels.length]);
692 this.labels.selectAll('.label-month').remove();
693 this.labels.selectAll('.label-month')
694 .data(month_labels)
695 .enter()
696 .append('text')
697 .attr('class', 'label label-month')
698 .attr('font-size', () => {
699 return Math.floor(this.label_padding / 3) + 'px';
700 })
701 .text((d: any) => {
702 return d.toLocaleDateString('en-us', {month: 'short'});
703 })
704 .attr('x', (d: any, i: number) => {
705 return monthScale(i) + (monthScale(i) - monthScale(i-1)) / 2;
706 })
707 .attr('y', this.label_padding / 2)
708 .on('mouseenter', (d: any) => {
709 if ( this.in_transition ) { return; }
710
711 var selected_month = moment(d);
712 this.items.selectAll('.item-circle')
713 .transition()
714 .duration(this.transition_duration)
715 .ease(d3.easeLinear)
716 .style('opacity', (d: any) => {
717 return moment(d.date).isSame(selected_month, 'month') ? 1 : 0.1;
718 });
719 })
720 .on('mouseout', () => {
721 if ( this.in_transition ) { return; }
722
723 this.items.selectAll('.item-circle')
724 .transition()
725 .duration(this.transition_duration)
726 .ease(d3.easeLinear)
727 .style('opacity', 1);
728 })
729 .on('click', (d: any) => {
730 if ( this.in_transition ) { return; }
731
732 // Check month data
733 var month_data = this.data.filter((e: any) => {
734 return moment(d).startOf('month') <= moment(e.date) && moment(e.date) < moment(d).endOf('month');
735 });
736
737 // Don't transition if there is no data to show
738 if ( !month_data.length ) { return; }
739
740 // Set selected month to the one clicked on
741 this.selected = {date: d};
742
743 this.in_transition = true;
744
745 // Hide tooltip
746 this.hideTooltip();
747
748 // Remove all year overview related items and labels
749 this.removeYearOverview();
750
751 // Redraw the chart
752 this.overview = 'month';
753 this.drawChart();
754 });
755
756 // Add day labels
757 var day_labels = d3.timeDays(
758 moment().startOf('week').toDate(),
759 moment().endOf('week').toDate()
760 );
761 var dayScale = d3.scaleBand()
762 .rangeRound([this.label_padding, this.height])
763 .domain(day_labels.map((d: any) => {
764 return moment(d).weekday().toString();
765 }));
766 this.labels.selectAll('.label-day').remove();
767 this.labels.selectAll('.label-day')
768 .data(day_labels)
769 .enter()
770 .append('text')
771 .attr('class', 'label label-day')
772 .attr('x', this.label_padding / 3)
773 .attr('y', (d: any, i: number) => {
774 return dayScale((i).toString()) + dayScale.bandwidth() / 1.75;
775 })
776 .style('text-anchor', 'left')
777 .attr('font-size', () => {
778 return Math.floor(this.label_padding / 3) + 'px';
779 })
780 .text((d: any) => {
781 return moment(d).format('dddd')[0];
782 })
783 .on('mouseenter', (d: any) => {
784 if ( this.in_transition ) { return; }
785
786 var selected_day = moment(d);
787 this.items.selectAll('.item-circle')
788 .transition()
789 .duration(this.transition_duration)
790 .ease(d3.easeLinear)
791 .style('opacity', (d: any) => {
792 return (moment(d.date).day() === selected_day.day()) ? 1 : 0.1;
793 });
794 })
795 .on('mouseout', () => {
796 if ( this.in_transition ) { return; }
797
798 this.items.selectAll('.item-circle')
799 .transition()
800 .duration(this.transition_duration)
801 .ease(d3.easeLinear)
802 .style('opacity', 1);
803 });
804
805 // Add button to switch back to previous overview
806 this.drawButton();
807 };
808
809
810 /**
811 * Draw month overview
812 */
813 drawMonthOverview() {
814 // Add current overview to the history
815 if ( this.history[this.history.length-1] !== this.overview ) {
816 this.history.push(this.overview);
817 }
818
819 // Define beginning and end of the month
820 var start_of_month = moment(this.selected['date']).startOf('month');
821 var end_of_month = moment(this.selected['date']).endOf('month');
822
823 // Filter data down to the selected month
824 var month_data = this.data.filter((d: any) => {
825 return start_of_month <= moment(d.date) && moment(d.date) < end_of_month;
826 });
827 var max_value: number = d3.max(month_data, (d: any) => {
828 return d3.max(d.summary, (d: any) => {
829 return +d.value;
830 });
831 });
832
833 // Define day labels and axis
834 var day_labels = d3.timeDays(moment().startOf('week').toDate(), moment().endOf('week').toDate());
835 var dayScale = d3.scaleBand()
836 .rangeRound([this.label_padding, this.height])
837 .domain(day_labels.map((d: any) => {
838 return moment(d).weekday().toString();
839 }));
840
841 // Define week labels and axis
842 var week_labels = [start_of_month.clone()];
843 while ( start_of_month.week() !== end_of_month.week() ) {
844 week_labels.push(start_of_month.add(1, 'week').clone());
845 }
846 var weekScale = d3.scaleBand()
847 .rangeRound([this.label_padding, this.width])
848 .padding(0.05)
849 .domain(week_labels.map((weekday) => {
850 return weekday.week().toString();
851 }));
852
853 // Add month data items to the overview
854 this.items.selectAll('.item-block-month').remove();
855 var item_block = this.items.selectAll('.item-block-month')
856 .data(month_data)
857 .enter()
858 .append('g')
859 .attr('class', 'item item-block-month')
860 .attr('width', () => {
861 return (this.width - this.label_padding) / week_labels.length - this.gutter * 5;
862 })
863 .attr('height', () => {
864 return Math.min(dayScale.bandwidth(), this.max_block_height);
865 })
866 .attr('transform', (d: any) => {
867 return 'translate(' + weekScale(moment(d.date).week().toString()) + ',' + ((dayScale(moment(d.date).weekday().toString()) + dayScale.bandwidth() / 1.75) - 15) + ')';
868 })
869 .attr('total', (d: any) => {
870 return d.total;
871 })
872 .attr('date', (d: any) => {
873 return d.date;
874 })
875 .attr('offset', 0)
876 .on('click', (d: any) => {
877 if ( this.in_transition ) { return; }
878
879 // Don't transition if there is no data to show
880 if ( d.total === 0 ) { return; }
881
882 this.in_transition = true;
883
884 // Set selected date to the one clicked on
885 this.selected = d;
886
887 // Hide tooltip
888 this.hideTooltip();
889
890 // Remove all month overview related items and labels
891 this.removeMonthOverview();
892
893 // Redraw the chart
894 this.overview = 'day';
895 this.drawChart();
896 });
897
898 var item_width = (this.width - this.label_padding) / week_labels.length - this.gutter * 5;
899 var itemScale = d3.scaleLinear()
900 .rangeRound([0, item_width]);
901
902 var item_gutter = this.item_gutter;
903 item_block.selectAll('.item-block-rect')
904 .data((d: any) => {
905 return d.summary;
906 })
907 .enter()
908 .append('rect')
909 .attr('class', 'item item-block-rect')
910 .attr('x', function (d: any) {
911 var total = parseInt(d3.select(this.parentNode).attr('total'));
912 var offset = parseInt(d3.select(this.parentNode).attr('offset'));
913 itemScale.domain([0, total]);
914 d3.select(this.parentNode).attr('offset', offset + itemScale(d.value));
915 return offset;
916 })
917 .attr('width', function (d: any) {
918 var total = parseInt(d3.select(this.parentNode).attr('total'));
919 itemScale.domain([0, total]);
920 return Math.max((itemScale(d.value) - item_gutter), 1)
921 })
922 .attr('height', () => {
923 return Math.min(dayScale.bandwidth(), this.max_block_height);
924 })
925 .attr('fill', (d: any) => {
926 var color = d3.scaleLinear<string>()
927 .range(['#ffffff', this.color])
928 .domain([-0.15 * max_value, max_value]);
929 return color(d.value) || '#ff4500';
930 })
931 .style('opacity', 0)
932 .on('mouseover', (d: any) => {
933 if ( this.in_transition ) { return; }
934
935 // Get date from the parent node
936 var date = new Date(d3.select(d3.event.currentTarget.parentNode).attr('date'));
937
938 // Construct tooltip
939 var tooltip_html = '';
940 tooltip_html += '<div class="header"><strong>' + d.name + '</strong></div><br>';
941 tooltip_html += '<div><strong>' + (d.value ? this.formatTime(d.value) : 'No time') + ' tracked</strong></div>';
942 tooltip_html += '<div>on ' + moment(date).format('dddd, MMM Do YYYY') + '</div>';
943
944 // Calculate tooltip position
945 var x = weekScale(moment(date).week().toString()) + this.tooltip_padding;
946 while ( this.width - x < (this.tooltip_width + this.tooltip_padding * 3) ) {
947 x -= 10;
948 }
949 var y = dayScale(moment(date).weekday().toString()) + this.tooltip_padding * 2;
950
951 // Show tooltip
952 this.tooltip.html(tooltip_html)
953 .style('left', x + 'px')
954 .style('top', y + 'px')
955 .transition()
956 .duration(this.transition_duration / 2)
957 .ease(d3.easeLinear)
958 .style('opacity', 1);
959 })
960 .on('mouseout', () => {
961 if ( this.in_transition ) { return; }
962 this.hideTooltip();
963 })
964 .transition()
965 .delay(() => {
966 return (Math.cos(Math.PI * Math.random()) + 1) * this.transition_duration;
967 })
968 .duration(() => {
969 return this.transition_duration;
970 })
971 .ease(d3.easeLinear)
972 .style('opacity', 1)
973 .call((transition: any, callback: any) => {
974 if ( transition.empty() ) {
975 callback();
976 }
977 var n = 0;
978 transition
979 .each(() => { ++n; })
980 .on('end', function () {
981 if ( !--n ) {
982 callback.apply(this, arguments);
983 }
984 });
985 }, () => {
986 this.in_transition = false;
987 });
988
989 // Add week labels
990 this.labels.selectAll('.label-week').remove();
991 this.labels.selectAll('.label-week')
992 .data(week_labels)
993 .enter()
994 .append('text')
995 .attr('class', 'label label-week')
996 .attr('font-size', () => {
997 return Math.floor(this.label_padding / 3) + 'px';
998 })
999 .text((d: any) => {
1000 return 'Week ' + d.week();
1001 })
1002 .attr('x', (d: any) => {
1003 return weekScale(d.week());
1004 })
1005 .attr('y', this.label_padding / 2)
1006 .on('mouseenter', (weekday: any) => {
1007 if ( this.in_transition ) { return; }
1008
1009 this.items.selectAll('.item-block-month')
1010 .transition()
1011 .duration(this.transition_duration)
1012 .ease(d3.easeLinear)
1013 .style('opacity', (d: any) => {
1014 return ( moment(d.date).week() === weekday.week() ) ? 1 : 0.1;
1015 });
1016 })
1017 .on('mouseout', () => {
1018 if ( this.in_transition ) { return; }
1019
1020 this.items.selectAll('.item-block-month')
1021 .transition()
1022 .duration(this.transition_duration)
1023 .ease(d3.easeLinear)
1024 .style('opacity', 1);
1025 })
1026 .on('click', (d: any) => {
1027 if ( this.in_transition ) { return; }
1028
1029 // Check week data
1030 var week_data = this.data.filter((e: any) => {
1031 return d.startOf('week') <= moment(e.date) && moment(e.date) < d.endOf('week');
1032 });
1033
1034 // Don't transition if there is no data to show
1035 if ( !week_data.length ) { return; }
1036
1037 this.in_transition = true;
1038
1039 // Set selected month to the one clicked on
1040 this.selected = { date: d };
1041
1042 // Hide tooltip
1043 this.hideTooltip();
1044
1045 // Remove all year overview related items and labels
1046 this.removeMonthOverview();
1047
1048 // Redraw the chart
1049 this.overview = 'week';
1050 this.drawChart();
1051 });
1052
1053 // Add day labels
1054 this.labels.selectAll('.label-day').remove();
1055 this.labels.selectAll('.label-day')
1056 .data(day_labels)
1057 .enter()
1058 .append('text')
1059 .attr('class', 'label label-day')
1060 .attr('x', this.label_padding / 3)
1061 .attr('y', (d: any, i: any) => {
1062 return dayScale(i) + dayScale.bandwidth() / 1.75;
1063 })
1064 .style('text-anchor', 'left')
1065 .attr('font-size', () => {
1066 return Math.floor(this.label_padding / 3) + 'px';
1067 })
1068 .text((d: any) => {
1069 return moment(d).format('dddd')[0];
1070 })
1071 .on('mouseenter', (d: any) => {
1072 if ( this.in_transition ) { return; }
1073
1074 var selected_day = moment(d);
1075 this.items.selectAll('.item-block-month')
1076 .transition()
1077 .duration(this.transition_duration)
1078 .ease(d3.easeLinear)
1079 .style('opacity', (d: any) => {
1080 return (moment(d.date).day() === selected_day.day()) ? 1 : 0.1;
1081 });
1082 })
1083 .on('mouseout', () => {
1084 if ( this.in_transition ) { return; }
1085
1086 this.items.selectAll('.item-block-month')
1087 .transition()
1088 .duration(this.transition_duration)
1089 .ease(d3.easeLinear)
1090 .style('opacity', 1);
1091 });
1092
1093 // Add button to switch back to previous overview
1094 this.drawButton();
1095 };
1096
1097
1098 /**
1099 * Draw week overview
1100 */
1101 drawWeekOverview() {
1102 // Add current overview to the history
1103 if ( this.history[this.history.length-1] !== this.overview ) {
1104 this.history.push(this.overview);
1105 }
1106
1107 // Define beginning and end of the week
1108 var start_of_week = moment(this.selected['date']).startOf('week');
1109 var end_of_week = moment(this.selected['date']).endOf('week');
1110
1111 // Filter data down to the selected week
1112 var week_data = this.data.filter((d: any) => {
1113 return start_of_week <= moment(d.date) && moment(d.date) < end_of_week;
1114 });
1115 var max_value: number = d3.max(week_data, (d: any) => {
1116 return d3.max(d.summary, (d: any) => {
1117 return +d.value;
1118 });
1119 });
1120
1121 // Define day labels and axis
1122 var day_labels = d3.timeDays(moment().startOf('week').toDate(), moment().endOf('week').toDate());
1123 var dayScale = d3.scaleBand()
1124 .rangeRound([this.label_padding, this.height])
1125 .domain(day_labels.map((d: any) => {
1126 return moment(d).weekday().toString();
1127 }));
1128
1129 // Define week labels and axis
1130 var week_labels = [start_of_week];
1131 var weekScale = d3.scaleBand()
1132 .rangeRound([this.label_padding, this.width])
1133 .padding(0.01)
1134 .domain(week_labels.map((weekday: any) => {
1135 return weekday.week();
1136 }));
1137
1138 // Add week data items to the overview
1139 this.items.selectAll('.item-block-week').remove();
1140 var item_block = this.items.selectAll('.item-block-week')
1141 .data(week_data)
1142 .enter()
1143 .append('g')
1144 .attr('class', 'item item-block-week')
1145 .attr('width', () => {
1146 return (this.width - this.label_padding) / week_labels.length - this.gutter * 5;
1147 })
1148 .attr('height', () => {
1149 return Math.min(dayScale.bandwidth(), this.max_block_height);
1150 })
1151 .attr('transform', (d: any) => {
1152 return 'translate(' + weekScale(moment(d.date).week().toString()) + ',' + ((dayScale(moment(d.date).weekday().toString()) + dayScale.bandwidth() / 1.75) - 15)+ ')';
1153 })
1154 .attr('total', (d: any) => {
1155 return d.total;
1156 })
1157 .attr('date', (d: any) => {
1158 return d.date;
1159 })
1160 .attr('offset', 0)
1161 .on('click', (d: any) => {
1162 if ( this.in_transition ) { return; }
1163
1164 // Don't transition if there is no data to show
1165 if ( d.total === 0 ) { return; }
1166
1167 this.in_transition = true;
1168
1169 // Set selected date to the one clicked on
1170 this.selected = d;
1171
1172 // Hide tooltip
1173 this.hideTooltip();
1174
1175 // Remove all week overview related items and labels
1176 this.removeWeekOverview();
1177
1178 // Redraw the chart
1179 this.overview = 'day';
1180 this.drawChart();
1181 });
1182
1183 var item_width = (this.width - this.label_padding) / week_labels.length - this.gutter * 5;
1184 var itemScale = d3.scaleLinear()
1185 .rangeRound([0, item_width]);
1186
1187 var item_gutter = this.item_gutter;
1188 item_block.selectAll('.item-block-rect')
1189 .data((d: any) => {
1190 return d.summary;
1191 })
1192 .enter()
1193 .append('rect')
1194 .attr('class', 'item item-block-rect')
1195 .attr('x', function (d: any) {
1196 var total = parseInt(d3.select(this.parentNode).attr('total'));
1197 var offset = parseInt(d3.select(this.parentNode).attr('offset'));
1198 itemScale.domain([0, total]);
1199 d3.select(this.parentNode).attr('offset', offset + itemScale(d.value));
1200 return offset;
1201 })
1202 .attr('width', function (d: any) {
1203 var total = parseInt(d3.select(this.parentNode).attr('total'));
1204 itemScale.domain([0, total]);
1205 return Math.max((itemScale(d.value) - item_gutter), 1)
1206 })
1207 .attr('height', () => {
1208 return Math.min(dayScale.bandwidth(), this.max_block_height);
1209 })
1210 .attr('fill', (d: any) => {
1211 var color = d3.scaleLinear<string>()
1212 .range(['#ffffff', this.color])
1213 .domain([-0.15 * max_value, max_value]);
1214 return color(d.value) || '#ff4500';
1215 })
1216 .style('opacity', 0)
1217 .on('mouseover', (d: any) => {
1218 if ( this.in_transition ) { return; }
1219
1220 // Get date from the parent node
1221 var date = new Date(d3.select(d3.event.currentTarget.parentNode).attr('date'));
1222
1223 // Construct tooltip
1224 var tooltip_html = '';
1225 tooltip_html += '<div class="header"><strong>' + d.name + '</strong></div><br>';
1226 tooltip_html += '<div><strong>' + (d.value ? this.formatTime(d.value) : 'No time') + ' tracked</strong></div>';
1227 tooltip_html += '<div>on ' + moment(date).format('dddd, MMM Do YYYY') + '</div>';
1228
1229 // Calculate tooltip position
1230 var total = parseInt(d3.select(d3.event.currentTarget.parentNode).attr('total'));
1231 itemScale.domain([0, total]);
1232 var x = parseInt(d3.select(d3.event.currentTarget).attr('x')) + itemScale(d.value) / 4 + this.tooltip_width / 4;
1233 while ( this.width - x < (this.tooltip_width + this.tooltip_padding * 3) ) {
1234 x -= 10;
1235 }
1236 var y = dayScale(moment(date).weekday().toString()) + this.tooltip_padding * 1.5;
1237
1238 // Show tooltip
1239 this.tooltip.html(tooltip_html)
1240 .style('left', x + 'px')
1241 .style('top', y + 'px')
1242 .transition()
1243 .duration(this.transition_duration / 2)
1244 .ease(d3.easeLinear)
1245 .style('opacity', 1);
1246 })
1247 .on('mouseout', () => {
1248 if ( this.in_transition ) { return; }
1249 this.hideTooltip();
1250 })
1251 .transition()
1252 .delay(() => {
1253 return (Math.cos(Math.PI * Math.random()) + 1) * this.transition_duration;
1254 })
1255 .duration(() => {
1256 return this.transition_duration;
1257 })
1258 .ease(d3.easeLinear)
1259 .style('opacity', 1)
1260 .call((transition: any, callback: any) => {
1261 if ( transition.empty() ) {
1262 callback();
1263 }
1264 var n = 0;
1265 transition
1266 .each(() => { ++n; })
1267 .on('end', function () {
1268 if ( !--n ) {
1269 callback.apply(this, arguments);
1270 }
1271 });
1272 }, () => {
1273 this.in_transition = false;
1274 });
1275
1276 // Add week labels
1277 this.labels.selectAll('.label-week').remove();
1278 this.labels.selectAll('.label-week')
1279 .data(week_labels)
1280 .enter()
1281 .append('text')
1282 .attr('class', 'label label-week')
1283 .attr('font-size', () => {
1284 return Math.floor(this.label_padding / 3) + 'px';
1285 })
1286 .text((d: any) => {
1287 return 'Week ' + d.week();
1288 })
1289 .attr('x', (d: any) => {
1290 return weekScale(d.week());
1291 })
1292 .attr('y', this.label_padding / 2)
1293 .on('mouseenter', (weekday: any) => {
1294 if ( this.in_transition ) { return; }
1295
1296 this.items.selectAll('.item-block-week')
1297 .transition()
1298 .duration(this.transition_duration)
1299 .ease(d3.easeLinear)
1300 .style('opacity', (d: any) => {
1301 return ( moment(d.date).week() === weekday.week() ) ? 1 : 0.1;
1302 });
1303 })
1304 .on('mouseout', () => {
1305 if ( this.in_transition ) { return; }
1306
1307 this.items.selectAll('.item-block-week')
1308 .transition()
1309 .duration(this.transition_duration)
1310 .ease(d3.easeLinear)
1311 .style('opacity', 1);
1312 });
1313
1314 // Add day labels
1315 this.labels.selectAll('.label-day').remove();
1316 this.labels.selectAll('.label-day')
1317 .data(day_labels)
1318 .enter()
1319 .append('text')
1320 .attr('class', 'label label-day')
1321 .attr('x', this.label_padding / 3)
1322 .attr('y', (d: any, i: number) => {
1323 return dayScale((i).toString()) + dayScale.bandwidth() / 1.75;
1324 })
1325 .style('text-anchor', 'left')
1326 .attr('font-size', () => {
1327 return Math.floor(this.label_padding / 3) + 'px';
1328 })
1329 .text((d: any) => {
1330 return moment(d).format('dddd')[0];
1331 })
1332 .on('mouseenter', (d: any) => {
1333 if ( this.in_transition ) { return; }
1334
1335 var selected_day = moment(d);
1336 this.items.selectAll('.item-block-week')
1337 .transition()
1338 .duration(this.transition_duration)
1339 .ease(d3.easeLinear)
1340 .style('opacity', (d: any) => {
1341 return (moment(d.date).day() === selected_day.day()) ? 1 : 0.1;
1342 });
1343 })
1344 .on('mouseout', () => {
1345 if ( this.in_transition ) { return; }
1346
1347 this.items.selectAll('.item-block-week')
1348 .transition()
1349 .duration(this.transition_duration)
1350 .ease(d3.easeLinear)
1351 .style('opacity', 1);
1352 });
1353
1354 // Add button to switch back to previous overview
1355 this.drawButton();
1356 };
1357
1358
1359 /**
1360 * Draw day overview
1361 */
1362 drawDayOverview() {
1363 // Add current overview to the history
1364 if ( this.history[this.history.length-1] !== this.overview ) {
1365 this.history.push(this.overview);
1366 }
1367
1368 // Initialize selected date to today if it was not set
1369 if ( !Object.keys(this.selected).length ) {
1370 this.selected = this.data[this.data.length - 1];
1371 }
1372
1373 var project_labels = this.selected['summary'].map((project: any) => {
1374 return project.name;
1375 });
1376 var projectScale = d3.scaleBand()
1377 .rangeRound([this.label_padding, this.height])
1378 .domain(project_labels);
1379
1380 var itemScale = d3.scaleTime()
1381 .range([this.label_padding * 2, this.width])
1382 .domain([moment(this.selected['date']).startOf('day'), moment(this.selected['date']).endOf('day')]);
1383 this.items.selectAll('.item-block').remove();
1384 this.items.selectAll('.item-block')
1385 .data(this.selected['details'])
1386 .enter()
1387 .append('rect')
1388 .attr('class', 'item item-block')
1389 .attr('x', (d: any) => {
1390 return itemScale(moment(d.date));
1391 })
1392 .attr('y', (d: any) => {
1393 return (projectScale(d.name) + projectScale.bandwidth() / 2) - 15;
1394 })
1395 .attr('width', (d: any) => {
1396 var end = itemScale(d3.timeSecond.offset(moment(d.date).toDate(), d.value));
1397 return Math.max((end - itemScale(moment(d.date))), 1);
1398 })
1399 .attr('height', () => {
1400 return Math.min(projectScale.bandwidth(), this.max_block_height);
1401 })
1402 .attr('fill', () => {
1403 return this.color;
1404 })
1405 .style('opacity', 0)
1406 .on('mouseover', (d: any) => {
1407 if ( this.in_transition ) { return; }
1408
1409 // Construct tooltip
1410 var tooltip_html = '';
1411 tooltip_html += '<div class="header"><strong>' + d.name + '</strong><div><br>';
1412 tooltip_html += '<div><strong>' + (d.value ? this.formatTime(d.value) : 'No time') + ' tracked</strong></div>';
1413 tooltip_html += '<div>on ' + moment(d.date).format('dddd, MMM Do YYYY HH:mm') + '</div>';
1414
1415 // Calculate tooltip position
1416 var x = d.value * 100 / (60 * 60 * 24) + itemScale(moment(d.date));
1417 while ( this.width - x < (this.tooltip_width + this.tooltip_padding * 3) ) {
1418 x -= 10;
1419 }
1420 var y = projectScale(d.name) + projectScale.bandwidth() / 2 + this.tooltip_padding / 2;
1421
1422 // Show tooltip
1423 this.tooltip.html(tooltip_html)
1424 .style('left', x + 'px')
1425 .style('top', y + 'px')
1426 .transition()
1427 .duration(this.transition_duration / 2)
1428 .ease(d3.easeLinear)
1429 .style('opacity', 1);
1430 })
1431 .on('mouseout', () => {
1432 if ( this.in_transition ) { return; }
1433 this.hideTooltip();
1434 })
1435 .on('click', (d: any) => {
1436 if ( this.handler ) {
1437 this.handler.emit(d);
1438 }
1439 })
1440 .transition()
1441 .delay(() => {
1442 return (Math.cos(Math.PI * Math.random()) + 1) * this.transition_duration;
1443 })
1444 .duration(() => {
1445 return this.transition_duration;
1446 })
1447 .ease(d3.easeLinear)
1448 .style('opacity', 0.5)
1449 .call((transition: any, callback: any) => {
1450 if ( transition.empty() ) {
1451 callback();
1452 }
1453 var n = 0;
1454 transition
1455 .each(() => { ++n; })
1456 .on('end', function () {
1457 if ( !--n ) {
1458 callback.apply(this, arguments);
1459 }
1460 });
1461 }, () => {
1462 this.in_transition = false;
1463 });
1464
1465 // Add time labels
1466 var timeLabels = d3.timeHours(
1467 moment(this.selected['date']).startOf('day').toDate(),
1468 moment(this.selected['date']).endOf('day').toDate()
1469 );
1470 var timeScale = d3.scaleTime()
1471 .range([this.label_padding * 2, this.width])
1472 .domain([0, timeLabels.length]);
1473 this.labels.selectAll('.label-time').remove();
1474 this.labels.selectAll('.label-time')
1475 .data(timeLabels)
1476 .enter()
1477 .append('text')
1478 .attr('class', 'label label-time')
1479 .attr('font-size', () => {
1480 return Math.floor(this.label_padding / 3) + 'px';
1481 })
1482 .text((d: any) => {
1483 return moment(d).format('HH:mm');
1484 })
1485 .attr('x', (d: any, i: number) => {
1486 return timeScale(i);
1487 })
1488 .attr('y', this.label_padding / 2)
1489 .on('mouseenter', (d: any) => {
1490 if ( this.in_transition ) { return; }
1491
1492 var selected = itemScale(moment(d));
1493 this.items.selectAll('.item-block')
1494 .transition()
1495 .duration(this.transition_duration)
1496 .ease(d3.easeLinear)
1497 .style('opacity', (d: any) => {
1498 var start = itemScale(moment(d.date));
1499 var end = itemScale(moment(d.date).add(d.value, 'seconds'));
1500 return ( selected >= start && selected <= end ) ? 1 : 0.1;
1501 });
1502 })
1503 .on('mouseout', () => {
1504 if ( this.in_transition ) { return; }
1505
1506 this.items.selectAll('.item-block')
1507 .transition()
1508 .duration(this.transition_duration)
1509 .ease(d3.easeLinear)
1510 .style('opacity', 0.5);
1511 });
1512
1513 // Add project labels
1514 var label_padding = this.label_padding;
1515 this.labels.selectAll('.label-project').remove();
1516 this.labels.selectAll('.label-project')
1517 .data(project_labels)
1518 .enter()
1519 .append('text')
1520 .attr('class', 'label label-project')
1521 .attr('x', this.gutter)
1522 .attr('y', (d: any) => {
1523 return projectScale(d) + projectScale.bandwidth() / 2;
1524 })
1525 .attr('min-height', () => {
1526 return projectScale.bandwidth();
1527 })
1528 .style('text-anchor', 'left')
1529 .attr('font-size', () => {
1530 return Math.floor(this.label_padding / 3) + 'px';
1531 })
1532 .text((d: any) => {
1533 return d;
1534 })
1535 .each(function (d: any, i: number) {
1536 var obj = d3.select(this),
1537 text_length = obj.node().getComputedTextLength(),
1538 text = obj.text();
1539 while (text_length > (label_padding * 1.5) && text.length > 0) {
1540 text = text.slice(0, -1);
1541 obj.text(text + '...');
1542 text_length = obj.node().getComputedTextLength();
1543 }
1544 })
1545 .on('mouseenter', (project: any) => {
1546 if ( this.in_transition ) { return; }
1547
1548 this.items.selectAll('.item-block')
1549 .transition()
1550 .duration(this.transition_duration)
1551 .ease(d3.easeLinear)
1552 .style('opacity', (d: any) => {
1553 return (d.name === project) ? 1 : 0.1;
1554 });
1555 })
1556 .on('mouseout', () => {
1557 if ( this.in_transition ) { return; }
1558
1559 this.items.selectAll('.item-block')
1560 .transition()
1561 .duration(this.transition_duration)
1562 .ease(d3.easeLinear)
1563 .style('opacity', 0.5);
1564 });
1565
1566 // Add button to switch back to previous overview
1567 this.drawButton();
1568 };
1569
1570
1571 /**
1572 * Helper function to calculate item position on the x-axis
1573 * @param d object
1574 */
1575 calcItemX(d: any, start_of_year: any) {
1576 var date = moment(d.date);
1577 var dayIndex = Math.round((+date - +moment(start_of_year).startOf('week')) / 86400000);
1578 var colIndex = Math.trunc(dayIndex / 7);
1579 return colIndex * (this.item_size + this.gutter) + this.label_padding;
1580 };
1581
1582
1583 /**
1584 * Helper function to calculate item position on the y-axis
1585 * @param d object
1586 */
1587 calcItemY(d: any) {
1588 return this.label_padding + moment(d.date).weekday() * (this.item_size + this.gutter);
1589 };
1590
1591
1592 /**
1593 * Helper function to calculate item size
1594 * @param d object
1595 * @param max number
1596 */
1597 calcItemSize(d: any, max: number) {
1598 if ( max <= 0 ) { return this.item_size; }
1599 return this.item_size * 0.75 + (this.item_size * d.total / max) * 0.25;
1600 };
1601
1602
1603 /**
1604 * Draw the button for navigation purposes
1605 */
1606 drawButton() {
1607 this.buttons.selectAll('.button').remove();
1608 var button = this.buttons.append('g')
1609 .attr('class', 'button button-back')
1610 .style('opacity', 0)
1611 .on('click', () => {
1612 if ( this.in_transition ) { return; }
1613
1614 // Set transition boolean
1615 this.in_transition = true;
1616
1617 // Clean the canvas from whichever overview type was on
1618 if ( this.overview === 'year' ) {
1619 this.removeYearOverview();
1620 } else if ( this.overview === 'month' ) {
1621 this.removeMonthOverview();
1622 } else if ( this.overview === 'week' ) {
1623 this.removeWeekOverview();
1624 } else if ( this.overview === 'day' ) {
1625 this.removeDayOverview();
1626 }
1627
1628 // Redraw the chart
1629 this.history.pop();
1630 this.overview = this.history.pop();
1631 this.drawChart();
1632 });
1633 button.append('circle')
1634 .attr('cx', this.label_padding / 2.25)
1635 .attr('cy', this.label_padding / 2.5)
1636 .attr('r', this.item_size / 2);
1637 button.append('text')
1638 .attr('x', this.label_padding / 2.25)
1639 .attr('y', this.label_padding / 2.5)
1640 .attr('dy', () => {
1641 return Math.floor(this.width / 100) / 3;
1642 })
1643 .attr('font-size', () => {
1644 return Math.floor(this.label_padding / 3) + 'px';
1645 })
1646 .html('&#x2190;');
1647 button.transition()
1648 .duration(this.transition_duration)
1649 .ease(d3.easeLinear)
1650 .style('opacity', 1);
1651 };
1652
1653
1654 /**
1655 * Transition and remove items and labels related to global overview
1656 */
1657 removeGlobalOverview() {
1658 this.items.selectAll('.item-block-year')
1659 .transition()
1660 .duration(this.transition_duration)
1661 .ease(d3.easeLinear)
1662 .style('opacity', 0)
1663 .remove();
1664 this.labels.selectAll('.label-year').remove();
1665 };
1666
1667
1668 /**
1669 * Transition and remove items and labels related to year overview
1670 */
1671 removeYearOverview() {
1672 this.items.selectAll('.item-circle')
1673 .transition()
1674 .duration(this.transition_duration)
1675 .ease(d3.easeLinear)
1676 .style('opacity', 0)
1677 .remove();
1678 this.labels.selectAll('.label-day').remove();
1679 this.labels.selectAll('.label-month').remove();
1680 this.hideBackButton();
1681 };
1682
1683
1684 /**
1685 * Transition and remove items and labels related to month overview
1686 */
1687 removeMonthOverview() {
1688 this.items.selectAll('.item-block-month').selectAll('.item-block-rect')
1689 .transition()
1690 .duration(this.transition_duration)
1691 .ease(d3.easeLinear)
1692 .style('opacity', 0)
1693 .attr('x', (d: any, i: number) => {
1694 return ( i % 2 === 0) ? -this.width/3 : this.width/3;
1695 })
1696 .remove();
1697 this.labels.selectAll('.label-day').remove();
1698 this.labels.selectAll('.label-week').remove();
1699 this.hideBackButton();
1700 };
1701
1702
1703 /**
1704 * Transition and remove items and labels related to week overview
1705 */
1706 removeWeekOverview() {
1707 this.items.selectAll('.item-block-week').selectAll('.item-block-rect')
1708 .transition()
1709 .duration(this.transition_duration)
1710 .ease(d3.easeLinear)
1711 .style('opacity', 0)
1712 .attr('x', (d: any, i: number) => {
1713 return ( i % 2 === 0) ? -this.width/3 : this.width/3;
1714 })
1715 .remove();
1716 this.labels.selectAll('.label-day').remove();
1717 this.labels.selectAll('.label-week').remove();
1718 this.hideBackButton();
1719 };
1720
1721
1722 /**
1723 * Transition and remove items and labels related to daily overview
1724 */
1725 removeDayOverview() {
1726 this.items.selectAll('.item-block')
1727 .transition()
1728 .duration(this.transition_duration)
1729 .ease(d3.easeLinear)
1730 .style('opacity', 0)
1731 .attr('x', (d: any, i: number) => {
1732 return ( i % 2 === 0) ? -this.width/3 : this.width/3;
1733 })
1734 .remove();
1735 this.labels.selectAll('.label-time').remove();
1736 this.labels.selectAll('.label-project').remove();
1737 this.hideBackButton();
1738 };
1739
1740
1741 /**
1742 * Helper function to hide the tooltip
1743 */
1744 hideTooltip() {
1745 this.tooltip.transition()
1746 .duration(this.transition_duration / 2)
1747 .ease(d3.easeLinear)
1748 .style('opacity', 0);
1749 };
1750
1751
1752 /**
1753 * Helper function to hide the back button
1754 */
1755 hideBackButton() {
1756 this.buttons.selectAll('.button')
1757 .transition()
1758 .duration(this.transition_duration)
1759 .ease(d3.easeLinear)
1760 .style('opacity', 0)
1761 .remove();
1762 };
1763
1764
1765 /**
1766 * Helper function to convert seconds to a human readable format
1767 * @param seconds Integer
1768 */
1769 formatTime(seconds: number) {
1770 var hours = Math.floor(seconds / 3600);
1771 var minutes = Math.floor((seconds - (hours * 3600)) / 60);
1772 var time = '';
1773 if ( hours > 0 ) {
1774 time += hours === 1 ? '1 hour ' : hours + ' hours ';
1775 }
1776 if ( minutes > 0 ) {
1777 time += minutes === 1 ? '1 minute' : minutes + ' minutes';
1778 }
1779 if ( hours === 0 && minutes === 0 ) {
1780 time = Math.round(seconds) + ' seconds';
1781 }
1782 return time;
1783 };
1784}