UNPKG

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