import _ from 'underscore'; import { scaleLinear, scaleThreshold } from 'd3-scale'; import { select } from 'd3-selection'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; const d3 = { select, scaleLinear, scaleThreshold }; const LOADING_HTML = `
`; function getSystemDate(systemUtcOffsetSeconds) { const date = new Date(); const localUtcOffsetMinutes = 0 - date.getTimezoneOffset(); const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60; date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes); return date; } function formatTooltipText({ date, count }) { const dateObject = new Date(date); const dateDayName = getDayName(dateObject); const dateText = dateObject.format('mmm d, yyyy'); let contribText = 'No contributions'; if (count > 0) { contribText = `${count} contribution${count > 1 ? 's' : ''}`; } return `${contribText}
${dateDayName} ${dateText}`; } const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); export default class ActivityCalendar { constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) { this.calendarActivitiesPath = calendarActivitiesPath; this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; this.daySizeWithSpace = this.daySize + (this.daySpace * 2); this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; this.months = []; // 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 = []; let group = 0; const today = getSystemDate(utcOffset); today.setHours(0, 0, 0, 0, 0); const oneYearAgo = new Date(today); oneYearAgo.setFullYear(today.getFullYear() - 1); const days = getDayDifference(oneYearAgo, today); for (let i = 0; i <= days; i += 1) { const date = new Date(oneYearAgo); date.setDate(date.getDate() + i); const day = date.getDay(); const count = timestamps[date.format('yyyy-mm-dd')] || 0; // Create a new group array if this is the first day of the week // or if is first object if ((day === 0 && i !== 0) || i === 0) { this.timestampsTmp.push([]); group += 1; } // Push to the inner array the values that will be used to render map const innerArray = this.timestampsTmp[group - 1]; innerArray.push({ count, date, day }); } // Init color functions this.colorKey = initColorKey(); this.color = this.initColor(); // Init the svg element this.svg = this.renderSvg(container, group); this.renderDays(); this.renderMonths(); this.renderDayTitles(); this.renderKey(); // Init tooltips $(`${container} .js-tooltip`).tooltip({ html: true }); } // Add extra padding for the last month label if it is also the last column getExtraWidthPadding(group) { let extraWidthPadding = 0; const lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth(); const secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth(); if (lastColMonth !== secondLastColMonth) { extraWidthPadding = 6; } return extraWidthPadding; } renderSvg(container, group) { const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group); return d3.select(container) .append('svg') .attr('width', width) .attr('height', 167) .attr('class', 'contrib-calendar'); } renderDays() { this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { if (a === 0 && stamp.day === 0) { const month = stamp.date.getMonth(); const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); if ( lastMonth == null || (month !== lastMonth.month && x - this.daySizeWithSpace !== lastMonth.x) ) { this.months.push({ month, x }); } } }); return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`; }) .selectAll('rect') .data(stamp => stamp) .enter() .append('rect') .attr('x', '0') .attr('y', stamp => this.daySizeWithSpace * 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); } renderDayTitles() { const days = [ { text: 'M', y: 29 + (this.daySizeWithSpace * 1), }, { text: 'W', y: 29 + (this.daySizeWithSpace * 3), }, { text: 'F', y: 29 + (this.daySizeWithSpace * 5), }, ]; this.svg.append('g') .selectAll('text') .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'); } renderMonths() { this.svg.append('g') .attr('direction', 'ltr') .selectAll('text') .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]); } renderKey() { 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)]; this.svg.append('g') .attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`) .selectAll('rect') .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'); } initColor() { 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); } clickDay(stamp) { if (this.currentSelectedDate !== stamp.date) { this.currentSelectedDate = stamp.date; const date = [ this.currentSelectedDate.getFullYear(), this.currentSelectedDate.getMonth() + 1, this.currentSelectedDate.getDate(), ].join('-'); $('.user-calendar-activities').html(LOADING_HTML); axios.get(this.calendarActivitiesPath, { params: { date, }, responseType: 'text', }) .then(({ data }) => $('.user-calendar-activities').html(data)) .catch(() => flash(__('An error occurred while retrieving calendar activity'))); } else { this.currentSelectedDate = ''; $('.user-calendar-activities').html(''); } } }