@senx/warpview
Version:
WarpView Elements
1,210 lines • 229 kB
JavaScript
/*
* Copyright 2021 SenX S.A.S.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import { Component, ElementRef, EventEmitter, HostListener, Input, Output, ViewChild, ViewEncapsulation } from '@angular/core';
import { Logger } from '../../../utils/logger';
import { Datum } from '../model/datum';
import { ChartLib } from '../../../utils/chart-lib';
import { easeLinear, max, range, scaleBand, scaleLinear, sum, timeDays } from 'd3';
import { event, select } from 'd3-selection';
import { ColorLib } from '../../../utils/color-lib';
import { GTSLib } from '../../../utils/gts.lib';
import moment from 'moment';
import * as i0 from "@angular/core";
export class CalendarHeatmapComponent {
constructor(el) {
this.el = el;
this.width = ChartLib.DEFAULT_WIDTH;
this.height = ChartLib.DEFAULT_HEIGHT;
this.overview = 'global';
this.handler = new EventEmitter();
// tslint:disable-next-line:no-output-native
this.change = new EventEmitter();
// tslint:disable-next-line:variable-name
this._debug = false;
// tslint:disable-next-line:variable-name
this._minColor = CalendarHeatmapComponent.DEF_MIN_COLOR;
// tslint:disable-next-line:variable-name
this._maxColor = CalendarHeatmapComponent.DEF_MAX_COLOR;
// Defaults
this.gutter = 5;
this.gWidth = 1000;
this.gHeight = 200;
this.itemSize = 10;
this.labelPadding = 40;
this.transitionDuration = 250;
this.inTransition = false;
// Tooltip defaults
this.tooltipWidth = 450;
this.tooltipPadding = 15;
// Overview defaults
this.history = ['global'];
this.selected = new Datum();
this.parentWidth = -1;
this.getTooltip = (d) => {
let tooltipHtml = '<div class="header"><strong>' + d.date.format('dddd, MMM Do YYYY HH:mm') + '</strong></div><ul>';
(d.summary || []).forEach(s => {
tooltipHtml += `<li>
<div class="round" style="background-color:${ColorLib.transparentize(s.color)}; border-color:${s.color}"></div>
${GTSLib.formatLabel(s.name)}: ${s.total}</li>`;
});
if (d.total !== undefined && d.name) {
tooltipHtml += `<li><div class="round"
style="background-color: ${ColorLib.transparentize(d.color)}; border-color: ${d.color}"
></div> ${GTSLib.formatLabel(d.name)}: ${d.total}</li>`;
}
tooltipHtml += '</ul>';
return tooltipHtml;
};
this.LOG = new Logger(CalendarHeatmapComponent, this.debug);
}
set debug(debug) {
this._debug = debug;
this.LOG.setDebug(debug);
}
get debug() {
return this._debug;
}
set data(data) {
this.LOG.debug(['data'], data);
if (data) {
this._data = data;
this.calculateDimensions();
}
}
get data() {
return this._data;
}
set minColor(minColor) {
this._minColor = minColor;
this.calculateDimensions();
}
get minColor() {
return this._minColor;
}
set maxColor(maxColor) {
this._maxColor = maxColor;
this.calculateDimensions();
}
get maxColor() {
return this._maxColor;
}
static getNumberOfWeeks() {
const dayIndex = Math.round((+moment.utc() - +moment.utc().subtract(1, 'year').startOf('week')) / 86400000);
return Math.trunc(dayIndex / 7) + 1;
}
onResize() {
if (this.el.nativeElement.parentElement.clientWidth !== this.parentWidth) {
this.calculateDimensions();
}
}
ngAfterViewInit() {
this.chart = this.div.nativeElement;
// Initialize svg element
this.svg = select(this.chart).append('svg').attr('class', 'svg');
// Initialize main svg elements
this.items = this.svg.append('g');
this.labels = this.svg.append('g');
this.buttons = this.svg.append('g');
// Add tooltip to the same element as main svg
this.tooltip = select(this.chart)
.append('div')
.attr('class', 'heatmap-tooltip')
.style('opacity', 0);
// Calculate chart dimensions
this.calculateDimensions();
// this.drawChart();
}
calculateDimensions() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
if (this.el.nativeElement.parentElement.clientWidth !== 0) {
this.gWidth = this.chart.clientWidth < 1000 ? 1000 : this.chart.clientWidth;
this.itemSize = ((this.gWidth - this.labelPadding) / CalendarHeatmapComponent.getNumberOfWeeks() - this.gutter);
this.gHeight = this.labelPadding + 7 * (this.itemSize + this.gutter);
this.svg.attr('width', this.gWidth).attr('height', this.gHeight);
this.LOG.debug(['calculateDimensions'], this._data);
if (!!this._data && !!this._data[0] && !!this._data[0].summary) {
this.drawChart();
}
}
else {
this.calculateDimensions();
}
}, 250);
}
groupBy(xs, key) {
return xs.reduce((rv, x) => {
(rv[x[key]] = rv[x[key]] || []).push(x);
return rv;
}, {});
}
updateDataSummary() {
// Get daily summary if that was not provided
if (!this._data[0].summary) {
this._data.map((d) => {
const summary = d.details.reduce((uniques, project) => {
if (!uniques[project.name]) {
uniques[project.name] = { value: project.value };
}
else {
uniques[project.name].value += project.value;
}
return uniques;
}, {});
const unsortedSummary = Object.keys(summary).map((key) => {
return {
name: key,
total: summary[key].value
};
});
d.summary = unsortedSummary.sort((a, b) => {
return b.total - a.total;
});
return d;
});
}
}
drawChart() {
if (!this.svg || !this._data) {
return;
}
this.LOG.debug(['drawChart'], [this.overview, this.selected]);
switch (this.overview) {
case 'global':
this.drawGlobalOverview();
this.change.emit({
overview: this.overview,
start: moment(this._data[0].date),
end: moment(this._data[this._data.length - 1].date),
});
break;
case 'year':
this.drawYearOverview();
this.change.emit({
overview: this.overview,
start: moment(this.selected.date).startOf('year'),
end: moment(this.selected.date).endOf('year'),
});
break;
case 'month':
this.drawMonthOverview();
this.change.emit({
overview: this.overview,
start: moment(this.selected.date).startOf('month'),
end: moment(this.selected.date).endOf('month'),
});
break;
case 'week':
this.drawWeekOverview();
this.change.emit({
overview: this.overview,
start: moment(this.selected.date).startOf('week'),
end: moment(this.selected.date).endOf('week'),
});
break;
case 'day':
this.drawDayOverview();
this.change.emit({
overview: this.overview,
start: moment(this.selected.date).startOf('day'),
end: moment(this.selected.date).endOf('day'),
});
break;
default:
break;
}
}
drawGlobalOverview() {
// Add current overview to the history
if (this.history[this.history.length - 1] !== this.overview) {
this.history.push(this.overview);
}
// Define start and end of the dataset
const startPeriod = moment.utc(this._data[0].date.startOf('y'));
const endPeriod = moment.utc(this._data[this._data.length - 1].date.endOf('y'));
// Define array of years and total values
const yData = this._data.filter((d) => d.date.isBetween(startPeriod, endPeriod, null, '[]'));
yData.forEach((d) => {
const summary = [];
const group = this.groupBy(d.details, 'name');
Object.keys(group).forEach(k => {
summary.push({
name: k,
total: group[k].reduce((acc, o) => {
return acc + o.value;
}, 0),
color: group[k][0].color,
id: group[k][0].id,
});
});
d.summary = summary;
});
const duration = Math.ceil(moment.duration(endPeriod.diff(startPeriod)).asYears());
const scale = [];
for (let i = 0; i < duration; i++) {
const d = moment.utc().year(startPeriod.year() + i).month(0).date(1).startOf('y');
scale.push(d);
}
let yearData = yData.map((d) => {
const date = d.date;
return {
date,
total: yData.reduce((prev, current) => {
if (current.date.year() === date.year()) {
prev += current.total;
}
return prev;
}, 0),
summary: (() => {
const summary = yData.reduce((s, data) => {
if (data.date.year() === date.year()) {
data.summary.forEach(_summary => {
if (!s[_summary.name]) {
s[_summary.name] = {
total: _summary.total,
color: _summary.color,
};
}
else {
s[_summary.name].total += _summary.total;
}
});
}
return s;
}, {});
const unsortedSummary = Object.keys(summary).map((key) => {
return {
name: key,
total: summary[key].total,
color: summary[key].color,
};
});
return unsortedSummary.sort((a, b) => b.total - a.total);
})(),
};
});
// Calculate max value of all the years in the dataset
yearData = GTSLib.cleanArray(yearData);
const maxValue = max(yearData, (d) => d.total);
// Define year labels and axis
const yearLabels = scale.map((d) => d);
const yearScale = scaleBand()
.rangeRound([0, this.gWidth])
.padding(0.05)
.domain(yearLabels.map((d) => d.year().toString()));
const color = scaleLinear()
.range([this.minColor || CalendarHeatmapComponent.DEF_MIN_COLOR, this.maxColor || CalendarHeatmapComponent.DEF_MAX_COLOR])
.domain([-0.15 * maxValue, maxValue]);
// Add global data items to the overview
this.items.selectAll('.item-block-year').remove();
this.items.selectAll('.item-block-year')
.data(yearData)
.enter()
.append('rect')
.attr('class', 'item item-block-year')
.attr('width', () => (this.gWidth - this.labelPadding) / yearLabels.length - this.gutter * 5)
.attr('height', () => this.gHeight - this.labelPadding)
.attr('transform', (d) => 'translate(' + yearScale(d.date.year().toString()) + ',' + this.tooltipPadding * 2 + ')')
.attr('fill', (d) => color(d.total) || CalendarHeatmapComponent.DEF_MAX_COLOR)
.on('click', (d) => {
if (this.inTransition) {
return;
}
// Set in_transition flag
this.inTransition = true;
// Set selected date to the one clicked on
this.selected = d;
// Hide tooltip
this.hideTooltip();
// Remove all global overview related items and labels
this.removeGlobalOverview();
// Redraw the chart
this.overview = 'year';
this.drawChart();
})
.style('opacity', 0)
.on('mouseover', (d) => {
if (this.inTransition) {
return;
}
// Calculate tooltip position
let x = yearScale(d.date.year().toString()) + this.tooltipPadding * 2;
while (this.gWidth - x < (this.tooltipWidth + this.tooltipPadding * 5)) {
x -= 10;
}
const y = this.tooltipPadding * 4;
// Show tooltip
this.tooltip.html(this.getTooltip(d))
.style('left', x + 'px')
.style('top', y + 'px')
.transition()
.duration(this.transitionDuration / 2)
.ease(easeLinear)
.style('opacity', 1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.hideTooltip();
})
.transition()
.delay((d, i) => this.transitionDuration * (i + 1) / 10)
.duration(() => this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1)
.call((transition, callback) => {
if (transition.empty()) {
callback();
}
let n = 0;
transition.each(() => ++n).on('end', function () {
if (!--n) {
callback.apply(this, arguments);
}
});
}, () => this.inTransition = false);
// Add year labels
this.labels.selectAll('.label-year').remove();
this.labels.selectAll('.label-year')
.data(yearLabels)
.enter()
.append('text')
.attr('class', 'label label-year')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => d.year())
.attr('x', (d) => yearScale(d.year().toString()))
.attr('y', this.labelPadding / 2)
.on('mouseenter', (yearLabel) => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-year')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (d) => (d.date.year() === yearLabel.year()) ? 1 : 0.1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-year')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
})
.on('click', () => {
if (this.inTransition) {
return;
}
// Set in_transition flag
this.inTransition = true;
// Set selected year to the one clicked on
this.selected = yearData[0];
// Hide tooltip
this.hideTooltip();
// Remove all global overview related items and labels
this.removeGlobalOverview();
// Redraw the chart
this.overview = 'year';
this.drawChart();
});
}
/**
* Draw year overview
*/
drawYearOverview() {
// Add current overview to the history
if (this.history[this.history.length - 1] !== this.overview) {
this.history.push(this.overview);
}
// Define start and end date of the selected year
const startOfYear = moment(this.selected.date).startOf('year');
const endOfYear = moment(this.selected.date).endOf('year');
// Filter data down to the selected year
let yearData = this._data.filter((d) => d.date.isBetween(startOfYear, endOfYear, null, '[]'));
yearData.forEach((d) => {
const summary = [];
const group = this.groupBy(d.details, 'name');
Object.keys(group).forEach(k => {
summary.push({
name: k,
total: group[k].reduce((acc, o) => {
return acc + o.value;
}, 0),
color: group[k][0].color,
id: group[k][0].id,
});
});
d.summary = summary;
});
yearData = GTSLib.cleanArray(yearData);
// Calculate max value of the year data
const maxValue = max(yearData, (d) => d.total);
const color = scaleLinear()
.range([this.minColor || CalendarHeatmapComponent.DEF_MIN_COLOR, this.maxColor || CalendarHeatmapComponent.DEF_MAX_COLOR])
.domain([-0.15 * maxValue, maxValue]);
this.items.selectAll('.item-circle').remove();
this.items.selectAll('.item-circle')
.data(yearData)
.enter()
.append('rect')
.attr('class', 'item item-circle').style('opacity', 0)
.attr('x', (d) => this.calcItemX(d, startOfYear) + (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('y', (d) => this.calcItemY(d) + (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('rx', (d) => this.calcItemSize(d, maxValue))
.attr('ry', (d) => this.calcItemSize(d, maxValue))
.attr('width', (d) => this.calcItemSize(d, maxValue))
.attr('height', (d) => this.calcItemSize(d, maxValue))
.attr('fill', (d) => (d.total > 0) ? color(d.total) : 'transparent')
.on('click', (d) => {
if (this.inTransition) {
return;
}
// Don't transition if there is no data to show
if (d.total === 0) {
return;
}
this.inTransition = true;
// Set selected date to the one clicked on
this.selected = d;
// Hide tooltip
this.hideTooltip();
// Remove all year overview related items and labels
this.removeYearOverview();
// Redraw the chart
this.overview = 'day';
this.drawChart();
})
.on('mouseover', (d) => {
if (this.inTransition) {
return;
}
// Pulsating animation
const circle = select(event.currentTarget);
const repeat = () => {
circle.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.attr('x', (data) => this.calcItemX(data, startOfYear) - (this.itemSize * 1.1 - this.itemSize) / 2)
.attr('y', (data) => this.calcItemY(data) - (this.itemSize * 1.1 - this.itemSize) / 2)
.attr('width', this.itemSize * 1.1)
.attr('height', this.itemSize * 1.1)
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.attr('x', (data) => this.calcItemX(data, startOfYear) + (this.itemSize - this.calcItemSize(data, maxValue)) / 2)
.attr('y', (data) => this.calcItemY(data) + (this.itemSize - this.calcItemSize(data, maxValue)) / 2)
.attr('width', (data) => this.calcItemSize(data, maxValue))
.attr('height', (data) => this.calcItemSize(data, maxValue))
.on('end', repeat);
};
repeat();
// Construct tooltip
// Calculate tooltip position
let x = this.calcItemX(d, startOfYear) + this.itemSize / 2;
if (this.gWidth - x < (this.tooltipWidth + this.tooltipPadding * 3)) {
x -= this.tooltipWidth + this.tooltipPadding * 2;
}
const y = this.calcItemY(d) + this.itemSize / 2;
// Show tooltip
this.tooltip.html(this.getTooltip(d))
.style('left', x + 'px')
.style('top', y + 'px')
.transition()
.duration(this.transitionDuration / 2)
.ease(easeLinear)
.style('opacity', 1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
// Set circle radius back to what it's supposed to be
select(event.currentTarget).transition()
.duration(this.transitionDuration / 2)
.ease(easeLinear)
.attr('x', (d) => this.calcItemX(d, startOfYear) + (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('y', (d) => this.calcItemY(d) + (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('width', (d) => this.calcItemSize(d, maxValue))
.attr('height', (d) => this.calcItemSize(d, maxValue));
// Hide tooltip
this.hideTooltip();
})
.transition()
.delay(() => (Math.cos(Math.PI * Math.random()) + 1) * this.transitionDuration)
.duration(() => this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1)
.call((transition, callback) => {
if (transition.empty()) {
callback();
}
let n = 0;
transition.each(() => ++n).on('end', function () {
if (!--n) {
callback.apply(this, arguments);
}
});
}, () => this.inTransition = false);
// Add month labels
const duration = Math.ceil(moment.duration(endOfYear.diff(startOfYear)).asMonths());
const monthLabels = [];
for (let i = 1; i < duration; i++) {
monthLabels.push(moment(this.selected.date).month((startOfYear.month() + i) % 12).startOf('month'));
}
const monthScale = scaleLinear().range([0, this.gWidth]).domain([0, monthLabels.length]);
this.labels.selectAll('.label-month').remove();
this.labels.selectAll('.label-month')
.data(monthLabels)
.enter()
.append('text')
.attr('class', 'label label-month')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => d.format('MMM'))
.attr('x', (d, i) => monthScale(i) + (monthScale(i) - monthScale(i - 1)) / 2)
.attr('y', this.labelPadding / 2)
.on('mouseenter', (d) => {
if (this.inTransition) {
return;
}
const selectedMonth = moment(d);
this.items.selectAll('.item-circle')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (data) => moment(data.date).isSame(selectedMonth, 'month') ? 1 : 0.1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-circle')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
})
.on('click', (d) => {
if (this.inTransition) {
return;
}
// Check month data
const monthData = this._data.filter((e) => e.date.isBetween(moment(d).startOf('month'), moment(d).endOf('month'), null, '[]'));
// Don't transition if there is no data to show
if (!monthData.length) {
return;
}
// Set selected month to the one clicked on
this.selected = { date: d };
this.inTransition = true;
// Hide tooltip
this.hideTooltip();
// Remove all year overview related items and labels
this.removeYearOverview();
// Redraw the chart
this.overview = 'month';
this.drawChart();
});
// Add day labels
const dayLabels = timeDays(moment.utc().startOf('week').toDate(), moment.utc().endOf('week').toDate());
const dayScale = scaleBand()
.rangeRound([this.labelPadding, this.gHeight])
.domain(dayLabels.map((d) => moment.utc(d).weekday().toString()));
this.labels.selectAll('.label-day').remove();
this.labels.selectAll('.label-day')
.data(dayLabels)
.enter()
.append('text')
.attr('class', 'label label-day')
.attr('x', this.labelPadding / 3)
.attr('y', (d, i) => dayScale(i.toString()) + dayScale.bandwidth() / 1.75)
.style('text-anchor', 'left')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => moment.utc(d).format('dddd')[0])
.on('mouseenter', (d) => {
if (this.inTransition) {
return;
}
const selectedDay = moment.utc(d);
this.items.selectAll('.item-circle')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (data) => (moment(data.date).day() === selectedDay.day()) ? 1 : 0.1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-circle')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
});
// Add button to switch back to previous overview
this.drawButton();
}
drawMonthOverview() {
// Add current overview to the history
if (this.history[this.history.length - 1] !== this.overview) {
this.history.push(this.overview);
}
// Define beginning and end of the month
const startOfMonth = moment(this.selected.date).startOf('month');
const endOfMonth = moment(this.selected.date).endOf('month');
// Filter data down to the selected month
let monthData = [];
this._data.filter((data) => data.date.isBetween(startOfMonth, endOfMonth, null, '[]'))
.map((d) => {
const scale = [];
d.details.forEach((det) => {
const date = moment.utc(det.date);
const i = Math.floor(date.hours() / 3);
if (!scale[i]) {
scale[i] = {
date: date.startOf('hour'),
total: 0,
details: [],
summary: []
};
}
scale[i].total += det.value;
scale[i].details.push(det);
});
scale.forEach((s) => {
const group = this.groupBy(s.details, 'name');
Object.keys(group).forEach((k) => {
s.summary.push({
name: k,
total: sum(group[k], (data) => data.total),
color: group[k][0].color
});
});
});
monthData = monthData.concat(scale);
});
monthData = GTSLib.cleanArray(monthData);
this.LOG.debug(['drawMonthOverview'], [this.overview, this.selected, monthData]);
const maxValue = max(monthData, (d) => d.total);
// Define day labels and axis
const dayLabels = timeDays(moment(this.selected.date).startOf('week').toDate(), moment(this.selected.date).endOf('week').toDate());
const dayScale = scaleBand()
.rangeRound([this.labelPadding, this.gHeight])
.domain(dayLabels.map((d) => moment.utc(d).weekday().toString()));
// Define week labels and axis
const weekLabels = [startOfMonth];
const incWeek = moment(startOfMonth);
while (incWeek.week() !== endOfMonth.week()) {
weekLabels.push(moment(incWeek.add(1, 'week')));
}
monthData.forEach((d) => {
const summary = [];
const group = this.groupBy(d.details, 'name');
Object.keys(group).forEach((k) => {
summary.push({
name: k,
total: group[k].reduce((acc, o) => acc + o.value, 0),
color: group[k][0].color,
id: group[k][0].id,
});
});
d.summary = summary;
});
const weekScale = scaleBand()
.rangeRound([this.labelPadding, this.gWidth])
.padding(0.05)
.domain(weekLabels.map((weekday) => weekday.week() + ''));
const color = scaleLinear()
.range([this.minColor || CalendarHeatmapComponent.DEF_MIN_COLOR, this.maxColor || CalendarHeatmapComponent.DEF_MAX_COLOR])
.domain([-0.15 * maxValue, maxValue]);
// Add month data items to the overview
this.items.selectAll('.item-block-month').remove();
this.items.selectAll('.item-block-month')
.data(monthData)
.enter().append('rect')
.style('opacity', 0)
.attr('class', 'item item-block-month')
.attr('y', (d) => this.calcItemY(d)
+ (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('x', (d) => this.calcItemXMonth(d, startOfMonth, weekScale(d.date.week().toString()))
+ (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('rx', (d) => this.calcItemSize(d, maxValue))
.attr('ry', (d) => this.calcItemSize(d, maxValue))
.attr('width', (d) => this.calcItemSize(d, maxValue))
.attr('height', (d) => this.calcItemSize(d, maxValue))
.attr('fill', (d) => (d.total > 0) ? color(d.total) : 'transparent')
.on('click', (d) => {
if (this.inTransition) {
return;
}
// Don't transition if there is no data to show
if (d.total === 0) {
return;
}
this.inTransition = true;
// Set selected date to the one clicked on
this.selected = d;
// Hide tooltip
this.hideTooltip();
// Remove all month overview related items and labels
this.removeMonthOverview();
// Redraw the chart
this.overview = 'day';
this.drawChart();
})
.on('mouseover', (d) => {
if (this.inTransition) {
return;
}
// Construct tooltip
// Calculate tooltip position
let x = weekScale(d.date.week().toString()) + this.tooltipPadding;
while (this.gWidth - x < (this.tooltipWidth + this.tooltipPadding * 3)) {
x -= 10;
}
const y = dayScale(d.date.weekday().toString()) + this.tooltipPadding;
// Show tooltip
this.tooltip.html(this.getTooltip(d))
.style('left', x + 'px')
.style('top', y + 'px')
.transition()
.duration(this.transitionDuration / 2)
.ease(easeLinear)
.style('opacity', 1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.hideTooltip();
})
.transition()
.delay(() => (Math.cos(Math.PI * Math.random()) + 1) * this.transitionDuration)
.duration(() => this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1)
.call((transition, callback) => {
if (transition.empty()) {
callback();
}
let n = 0;
transition.each(() => ++n).on('end', function () {
if (!--n) {
callback.apply(this, arguments);
}
});
}, () => this.inTransition = false);
// Add week labels
this.labels.selectAll('.label-week').remove();
this.labels.selectAll('.label-week')
.data(weekLabels)
.enter()
.append('text')
.attr('class', 'label label-week')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => 'Week ' + d.week())
.attr('x', (d) => weekScale(d.week().toString()))
.attr('y', this.labelPadding / 2)
.on('mouseenter', (weekday) => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-month')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (d) => {
return (moment(d.date).week() === weekday.week()) ? 1 : 0.1;
});
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-month')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
})
.on('click', (d) => {
if (this.inTransition) {
return;
}
this.inTransition = true;
// Set selected month to the one clicked on
this.selected = { date: d };
// Hide tooltip
this.hideTooltip();
// Remove all year overview related items and labels
this.removeMonthOverview();
// Redraw the chart
this.overview = 'week';
this.drawChart();
});
// Add day labels
this.labels.selectAll('.label-day').remove();
this.labels.selectAll('.label-day')
.data(dayLabels)
.enter()
.append('text')
.attr('class', 'label label-day')
.attr('x', this.labelPadding / 3)
.attr('y', (d, i) => dayScale(i) + dayScale.bandwidth() / 1.75)
.style('text-anchor', 'left')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => moment.utc(d).format('dddd')[0])
.on('mouseenter', (d) => {
if (this.inTransition) {
return;
}
const selectedDay = moment.utc(d);
this.items.selectAll('.item-block-month')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (data) => (moment(data.date).day() === selectedDay.day()) ? 1 : 0.1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-month')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
});
// Add button to switch back to previous overview
this.drawButton();
}
drawWeekOverview() {
// Add current overview to the history
if (this.history[this.history.length - 1] !== this.overview) {
this.history.push(this.overview);
}
// Define beginning and end of the week
const startOfWeek = moment(this.selected.date).startOf('week');
const endOfWeek = moment(this.selected.date).endOf('week');
// Filter data down to the selected week
let weekData = [];
this._data.filter((d) => {
return d.date.isBetween(startOfWeek, endOfWeek, null, '[]');
}).map((d) => {
const scale = [];
d.details.forEach((det) => {
const date = moment(det.date);
const i = date.hours();
if (!scale[i]) {
scale[i] = {
date: date.startOf('hour'),
total: 0,
details: [],
summary: []
};
}
scale[i].total += det.value;
scale[i].details.push(det);
});
scale.forEach(s => {
const group = this.groupBy(s.details, 'name');
Object.keys(group).forEach(k => s.summary.push({
name: k,
total: sum(group[k], (data) => data.value),
color: group[k][0].color
}));
});
weekData = weekData.concat(scale);
});
weekData = GTSLib.cleanArray(weekData);
const maxValue = max(weekData, (d) => d.total);
// Define day labels and axis
const dayLabels = timeDays(moment.utc().startOf('week').toDate(), moment.utc().endOf('week').toDate());
const dayScale = scaleBand()
.rangeRound([this.labelPadding, this.gHeight])
.domain(dayLabels.map((d) => moment.utc(d).weekday().toString()));
// Define hours labels and axis
const hoursLabels = [];
range(0, 24).forEach(h => hoursLabels.push(moment.utc().hours(h).startOf('hour').format('HH:mm')));
const hourScale = scaleBand().rangeRound([this.labelPadding, this.gWidth]).padding(0.01).domain(hoursLabels);
const color = scaleLinear()
.range([this.minColor || CalendarHeatmapComponent.DEF_MIN_COLOR, this.maxColor || CalendarHeatmapComponent.DEF_MAX_COLOR])
.domain([-0.15 * maxValue, maxValue]);
// Add week data items to the overview
this.items.selectAll('.item-block-week').remove();
this.items.selectAll('.item-block-week')
.data(weekData)
.enter()
.append('rect')
.style('opacity', 0)
.attr('class', 'item item-block-week')
.attr('y', (d) => this.calcItemY(d)
+ (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('x', (d) => this.gutter
+ hourScale(moment(d.date).startOf('hour').format('HH:mm'))
+ (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('rx', (d) => this.calcItemSize(d, maxValue))
.attr('ry', (d) => this.calcItemSize(d, maxValue))
.attr('width', (d) => this.calcItemSize(d, maxValue))
.attr('height', (d) => this.calcItemSize(d, maxValue))
.attr('fill', (d) => (d.total > 0) ? color(d.total) : 'transparent')
.on('click', (d) => {
if (this.inTransition) {
return;
}
// Don't transition if there is no data to show
if (d.total === 0) {
return;
}
this.inTransition = true;
// Set selected date to the one clicked on
this.selected = d;
// Hide tooltip
this.hideTooltip();
// Remove all week overview related items and labels
this.removeWeekOverview();
// Redraw the chart
this.overview = 'day';
this.drawChart();
}).on('mouseover', (d) => {
if (this.inTransition) {
return;
}
// Calculate tooltip position
let x = hourScale(moment(d.date).startOf('hour').format('HH:mm')) + this.tooltipPadding;
while (this.gWidth - x < (this.tooltipWidth + this.tooltipPadding * 3)) {
x -= 10;
}
const y = dayScale(d.date.weekday().toString()) + this.tooltipPadding;
// Show tooltip
this.tooltip.html(this.getTooltip(d))
.style('left', x + 'px')
.style('top', y + 'px')
.transition()
.duration(this.transitionDuration / 2)
.ease(easeLinear)
.style('opacity', 1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.hideTooltip();
})
.transition()
.delay(() => (Math.cos(Math.PI * Math.random()) + 1) * this.transitionDuration)
.duration(() => this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1)
.call((transition, callback) => {
if (transition.empty()) {
callback();
}
let n = 0;
transition.each(() => ++n).on('end', function () {
if (!--n) {
callback.apply(this, arguments);
}
});
}, () => this.inTransition = false);
// Add week labels
this.labels.selectAll('.label-week').remove();
this.labels.selectAll('.label-week')
.data(hoursLabels)
.enter()
.append('text')
.attr('class', 'label label-week')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => d)
.attr('x', (d) => hourScale(d))
.attr('y', this.labelPadding / 2)
.on('mouseenter', (hour) => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-week')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (d) => (moment(d.date).startOf('hour').format('HH:mm') === hour) ? 1 : 0.1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-week')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
});
// Add day labels
this.labels.selectAll('.label-day').remove();
this.labels.selectAll('.label-day')
.data(dayLabels)
.enter()
.append('text')
.attr('class', 'label label-day')
.attr('x', this.labelPadding / 3)
.attr('y', (d, i) => dayScale(i.toString()) + dayScale.bandwidth() / 1.75)
.style('text-anchor', 'left')
.attr('font-size', () => Math.floor(this.labelPadding / 3) + 'px')
.text((d) => moment.utc(d).format('dddd')[0])
.on('mouseenter', (d) => {
if (this.inTransition) {
return;
}
const selectedDay = moment.utc(d);
this.items.selectAll('.item-block-week')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', (data) => (moment(data.date).day() === selectedDay.day()) ? 1 : 0.1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.items.selectAll('.item-block-week')
.transition()
.duration(this.transitionDuration)
.ease(easeLinear)
.style('opacity', 1);
});
// Add button to switch back to previous overview
this.drawButton();
}
drawDayOverview() {
// Add current overview to the history
if (this.history[this.history.length - 1] !== this.overview) {
this.history.push(this.overview);
}
// Initialize selected date to today if it was not set
if (!Object.keys(this.selected).length) {
this.selected = this._data[this._data.length - 1];
}
const startOfDay = moment(this.selected.date).startOf('day');
const endOfDay = moment(this.selected.date).endOf('day');
// Filter data down to the selected month
let dayData = [];
this._data.filter((d) => {
return d.date.isBetween(startOfDay, endOfDay, null, '[]');
}).map((d) => {
const scale = [];
d.details.forEach((det) => {
const date = moment(det.date);
const i = date.hours();
if (!scale[i]) {
scale[i] = {
date: date.startOf('hour'),
total: 0,
details: [],
summary: []
};
}
scale[i].total += det.value;
scale[i].details.push(det);
});
scale.forEach(s => {
const group = this.groupBy(s.details, 'name');
Object.keys(group).forEach(k => {
s.summary.push({
name: k,
total: sum(group[k], (item) => item.value),
color: group[k][0].color
});
});
});
dayData = dayData.concat(scale);
});
const data = [];
dayData.forEach((d) => {
const date = d.date;
d.summary.forEach((s) => {
s.date = date;
data.push(s);
});
});
dayData = GTSLib.cleanArray(dayData);
const maxValue = max(data, (d) => d.total);
const gtsNames = this.selected.summary.map((summary) => summary.name);
const gtsNameScale = scaleBand().rangeRound([this.labelPadding, this.gHeight]).domain(gtsNames);
const hourLabels = [];
range(0, 24).forEach(h => hourLabels.push(moment.utc().hours(h).startOf('hour').format('HH:mm')));
const dayScale = scaleBand()
.rangeRound([this.labelPadding, this.gWidth])
.padding(0.01)
.domain(hourLabels);
this.items.selectAll('.item-block').remove();
this.items.selectAll('.item-block')
.data(data)
.enter()
.append('rect')
.attr('class', 'item item-block')
.attr('x', (d) => this.gutter
+ dayScale(moment(d.date).startOf('hour').format('HH:mm'))
+ (this.itemSize - this.calcItemSize(d, maxValue)) / 2)
.attr('y', (d) => {
return (gtsNameScale(d.name) || 1) - (this.itemSize - this.calcItemSize(d, maxValue)) / 2;
})
.attr('rx', (d) => this.calcItemSize(d, maxValue))
.attr('ry', (d) => this.calcItemSize(d, maxValue))
.attr('width', (d) => this.calcItemSize(d, maxValue))
.attr('height', (d) => this.calcItemSize(d, maxValue))
.attr('fill', (d) => {
const color = scaleLinear()
.range(['#ffffff', d.color || CalendarHeatmapComponent.DEF_MIN_COLOR])
.domain([-0.5 * maxValue, maxValue]);
return color(d.total);
})
.style('opacity', 0)
.on('mouseover', (d) => {
if (this.inTransition) {
return;
}
// Calculate tooltip position
let x = dayScale(moment(d.date).startOf('hour').format('HH:mm')) + this.tooltipPadding;
while (this.gWidth - x < (this.tooltipWidth + this.tooltipPadding * 3)) {
x -= 10;
}
const y = gtsNameScale(d.name) + this.tooltipPadding;
// Show tooltip
this.tooltip.html(this.getTooltip(d))
.style('left', x + 'px')
.style('top', y + 'px')
.transition()
.duration(this.transitionDuration / 2)
.ease(easeLinear)
.style('opacity', 1);
})
.on('mouseout', () => {
if (this.inTransition) {
return;
}
this.hideTooltip();
})
.on('click', (d) => {
if (this.handler) {
this.handler.emit(d);
}
})
.transition()
.delay(() => (Math.cos(Math.PI * Math.random()) + 1) * this.transitionDurat