gitlab-org--gitlab-foss/app/assets/javascripts/pages/users/activity_calendar.js

310 lines
8.2 KiB
JavaScript
Raw Normal View History

import $ from 'jquery';
import _ from 'underscore';
import { scaleLinear, scaleThreshold } from 'd3-scale';
import { select } from 'd3-selection';
import dateFormat from 'dateformat';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { __ } from '~/locale';
2017-07-14 11:06:49 -04:00
const d3 = { select, scaleLinear, scaleThreshold };
const LOADING_HTML = `
<div class="text-center">
<i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
</div>
`;
function getSystemDate(systemUtcOffsetSeconds) {
const date = new Date();
const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60;
2018-04-18 16:43:15 -04:00
date.setMinutes(date.getMinutes() - localUtcOffsetMinutes + systemUtcOffsetMinutes);
return date;
}
2017-07-27 17:36:48 -04:00
function formatTooltipText({ date, count }) {
const dateObject = new Date(date);
const dateDayName = getDayName(dateObject);
const dateText = dateFormat(dateObject, 'mmm d, yyyy');
2017-07-27 17:36:48 -04:00
let contribText = 'No contributions';
if (count > 0) {
contribText = `${count} contribution${count > 1 ? 's' : ''}`;
}
return `${contribText}<br />${dateDayName} ${dateText}`;
}
2018-04-18 16:43:15 -04:00
const initColorKey = () =>
d3
.scaleLinear()
.range(['#acd5f2', '#254e77'])
.domain([0, 3]);
2017-07-27 17:36:48 -04:00
2017-07-14 11:06:49 -04:00
export default class ActivityCalendar {
constructor(
container,
activitiesContainer,
timestamps,
calendarActivitiesPath,
utcOffset = 0,
firstDayOfWeek = 0,
monthsAgo = 12,
) {
2017-07-27 16:19:59 -04:00
this.calendarActivitiesPath = calendarActivitiesPath;
2017-07-14 11:06:49 -04:00
this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
this.daySpace = 1;
this.daySize = 15;
2018-04-18 16:43:15 -04:00
this.daySizeWithSpace = this.daySize + this.daySpace * 2;
this.monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
2017-07-14 11:06:49 -04:00
this.months = [];
2018-04-18 16:43:15 -04:00
this.firstDayOfWeek = firstDayOfWeek;
this.activitiesContainer = activitiesContainer;
this.container = container;
2017-07-14 11:06:49 -04:00
// Loop through the timestamps to create a group of objects
// The group of objects will be grouped based on the day of the week they are
this.timestampsTmp = [];
2017-07-27 17:36:48 -04:00
let group = 0;
2017-07-14 11:06:49 -04:00
const today = getSystemDate(utcOffset);
2017-07-14 11:06:49 -04:00
today.setHours(0, 0, 0, 0, 0);
const timeAgo = new Date(today);
timeAgo.setMonth(today.getMonth() - monthsAgo);
2017-07-14 11:06:49 -04:00
const days = getDayDifference(timeAgo, today);
2017-07-14 11:06:49 -04:00
2017-07-27 17:36:48 -04:00
for (let i = 0; i <= days; i += 1) {
const date = new Date(timeAgo);
2017-07-14 11:06:49 -04:00
date.setDate(date.getDate() + i);
2017-07-27 17:36:48 -04:00
const day = date.getDay();
const count = timestamps[dateFormat(date, 'yyyy-mm-dd')] || 0;
2017-07-14 11:06:49 -04:00
// Create a new group array if this is the first day of the week
// or if is first object
2018-04-18 16:43:15 -04:00
if ((day === this.firstDayOfWeek && i !== 0) || i === 0) {
2017-07-14 11:06:49 -04:00
this.timestampsTmp.push([]);
group += 1;
}
// Push to the inner array the values that will be used to render map
2017-07-27 17:36:48 -04:00
const innerArray = this.timestampsTmp[group - 1];
innerArray.push({ count, date, day });
2017-07-14 11:06:49 -04:00
}
// Init color functions
2017-07-27 17:36:48 -04:00
this.colorKey = initColorKey();
2017-07-14 11:06:49 -04:00
this.color = this.initColor();
2017-07-14 11:06:49 -04:00
// Init the svg element
2017-07-27 17:36:48 -04:00
this.svg = this.renderSvg(container, group);
2017-07-14 11:06:49 -04:00
this.renderDays();
this.renderMonths();
this.renderDayTitles();
this.renderKey();
2017-07-27 17:36:48 -04:00
// Init tooltips
$(`${container} .js-tooltip`).tooltip({ html: true });
2017-07-14 11:06:49 -04:00
}
// Add extra padding for the last month label if it is also the last column
getExtraWidthPadding(group) {
2017-07-27 17:36:48 -04:00
let extraWidthPadding = 0;
const lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth();
const secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth();
2017-07-14 11:06:49 -04:00
if (lastColMonth !== secondLastColMonth) {
2018-01-29 11:38:14 -05:00
extraWidthPadding = 6;
2017-07-14 11:06:49 -04:00
}
return extraWidthPadding;
}
2017-07-27 17:36:48 -04:00
renderSvg(container, group) {
2018-04-18 16:43:15 -04:00
const width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
return d3
.select(container)
.append('svg')
2018-04-18 16:43:15 -04:00
.attr('width', width)
.attr('height', 167)
.attr('class', 'contrib-calendar');
}
dayYPos(day) {
return this.daySizeWithSpace * ((day + 7 - this.firstDayOfWeek) % 7);
2017-07-14 11:06:49 -04:00
}
renderDays() {
2018-04-18 16:43:15 -04:00
this.svg
.selectAll('g')
.data(this.timestampsTmp)
.enter()
.append('g')
.attr('transform', (group, i) => {
_.each(group, (stamp, a) => {
2017-07-14 11:06:49 -04:00
if (a === 0 && stamp.day === 0) {
2017-07-27 17:36:48 -04:00
const month = stamp.date.getMonth();
2018-04-18 16:43:15 -04:00
const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace;
2017-07-27 17:36:48 -04:00
const lastMonth = _.last(this.months);
if (
lastMonth == null ||
(month !== lastMonth.month && x - this.daySizeWithSpace !== lastMonth.x)
) {
this.months.push({ month, x });
2017-07-14 11:06:49 -04:00
}
}
});
2018-04-18 16:43:15 -04:00
return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`;
})
.selectAll('rect')
2018-04-18 16:43:15 -04:00
.data(stamp => stamp)
.enter()
.append('rect')
.attr('x', '0')
.attr('y', stamp => this.dayYPos(stamp.day))
.attr('width', this.daySize)
.attr('height', this.daySize)
.attr(
'fill',
stamp => (stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'),
)
.attr('title', stamp => formatTooltipText(stamp))
.attr('class', 'user-contrib-cell js-tooltip')
.attr('data-container', 'body')
.on('click', this.clickDay);
2017-07-14 11:06:49 -04:00
}
renderDayTitles() {
const days = [
2017-07-14 11:06:49 -04:00
{
text: 'M',
2018-04-18 16:43:15 -04:00
y: 29 + this.dayYPos(1),
},
{
2017-07-14 11:06:49 -04:00
text: 'W',
2018-04-26 13:06:07 -04:00
y: 29 + this.dayYPos(3),
2018-04-18 16:43:15 -04:00
},
{
2017-07-14 11:06:49 -04:00
text: 'F',
2018-04-26 13:06:07 -04:00
y: 29 + this.dayYPos(5),
},
2017-07-14 11:06:49 -04:00
];
2018-04-18 16:43:15 -04:00
this.svg
.append('g')
.selectAll('text')
2018-04-18 16:43:15 -04:00
.data(days)
.enter()
.append('text')
.attr('text-anchor', 'middle')
.attr('x', 8)
.attr('y', day => day.y)
.text(day => day.text)
.attr('class', 'user-contrib-text');
2017-07-14 11:06:49 -04:00
}
renderMonths() {
2018-04-18 16:43:15 -04:00
this.svg
.append('g')
.attr('direction', 'ltr')
.selectAll('text')
2018-04-18 16:43:15 -04:00
.data(this.months)
.enter()
.append('text')
.attr('x', date => date.x)
.attr('y', 10)
.attr('class', 'user-contrib-text')
.text(date => this.monthNames[date.month]);
2017-07-14 11:06:49 -04:00
}
renderKey() {
2018-04-18 16:43:15 -04:00
const keyValues = [
'no contributions',
'1-9 contributions',
'10-19 contributions',
'20-29 contributions',
'30+ contributions',
];
const keyColors = [
'#ededed',
this.colorKey(0),
this.colorKey(1),
this.colorKey(2),
this.colorKey(3),
];
2017-07-14 11:06:49 -04:00
2018-04-18 16:43:15 -04:00
this.svg
.append('g')
.attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
2017-07-14 11:06:49 -04:00
.selectAll('rect')
2018-04-18 16:43:15 -04:00
.data(keyColors)
.enter()
.append('rect')
.attr('width', this.daySize)
.attr('height', this.daySize)
.attr('x', (color, i) => this.daySizeWithSpace * i)
.attr('y', 0)
.attr('fill', color => color)
.attr('class', 'js-tooltip')
.attr('title', (color, i) => keyValues[i])
.attr('data-container', 'body');
2017-07-14 11:06:49 -04:00
}
initColor() {
2018-04-18 16:43:15 -04:00
const colorRange = [
'#ededed',
this.colorKey(0),
this.colorKey(1),
this.colorKey(2),
this.colorKey(3),
];
return d3
.scaleThreshold()
.domain([0, 10, 20, 30])
.range(colorRange);
2017-07-14 11:06:49 -04:00
}
clickDay(stamp) {
if (this.currentSelectedDate !== stamp.date) {
this.currentSelectedDate = stamp.date;
const date = [
this.currentSelectedDate.getFullYear(),
this.currentSelectedDate.getMonth() + 1,
this.currentSelectedDate.getDate(),
].join('-');
$(this.activitiesContainer).html(LOADING_HTML);
2018-04-18 16:43:15 -04:00
axios
.get(this.calendarActivitiesPath, {
params: {
date,
},
responseType: 'text',
})
.then(({ data }) => $(this.activitiesContainer).html(data))
2018-04-18 16:43:15 -04:00
.catch(() => flash(__('An error occurred while retrieving calendar activity')));
2017-07-14 11:06:49 -04:00
} else {
this.currentSelectedDate = '';
$(this.activitiesContainer).html('');
2017-07-14 11:06:49 -04:00
}
}
}