<script> import {SvgIcon} from '../svg.js'; import { Chart, Title, BarElement, LinearScale, TimeScale, PointElement, LineElement, Filler, } from 'chart.js'; import {GET} from '../modules/fetch.js'; import zoomPlugin from 'chartjs-plugin-zoom'; import {Line as ChartLine} from 'vue-chartjs'; import { startDaysBetween, firstStartDateAfterDate, fillEmptyStartDaysWithZeroes, } from '../utils/time.js'; import {chartJsColors} from '../utils/color.js'; import {sleep} from '../utils.js'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; import $ from 'jquery'; const {pageData} = window.config; const customEventListener = { id: 'customEventListener', afterEvent: (chart, args, opts) => { // event will be replayed from chart.update when reset zoom, // so we need to check whether args.replay is true to avoid call loops if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) { chart.resetZoom(); opts.instance.updateOtherCharts(args.event, true); } }, }; Chart.defaults.color = chartJsColors.text; Chart.defaults.borderColor = chartJsColors.border; Chart.register( TimeScale, LinearScale, BarElement, Title, PointElement, LineElement, Filler, zoomPlugin, customEventListener, ); export default { components: {ChartLine, SvgIcon}, props: { locale: { type: Object, required: true, }, }, data: () => ({ isLoading: false, errorText: '', totalStats: {}, sortedContributors: {}, repoLink: pageData.repoLink || [], type: pageData.contributionType, contributorsStats: [], xAxisStart: null, xAxisEnd: null, xAxisMin: null, xAxisMax: null, }), mounted() { this.fetchGraphData(); $('#repo-contributors').dropdown({ onChange: (val) => { this.xAxisMin = this.xAxisStart; this.xAxisMax = this.xAxisEnd; this.type = val; this.sortContributors(); }, }); }, methods: { sortContributors() { const contributors = this.filterContributorWeeksByDateRange(); const criteria = `total_${this.type}`; this.sortedContributors = Object.values(contributors) .filter((contributor) => contributor[criteria] !== 0) .sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1) .slice(0, 100); }, async fetchGraphData() { this.isLoading = true; try { let response; do { response = await GET(`${this.repoLink}/activity/contributors/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } } while (response.status === 202); if (response.ok) { const data = await response.json(); const {total, ...rest} = data; // below line might be deleted if we are sure go produces map always sorted by keys total.weeks = Object.fromEntries(Object.entries(total.weeks).sort()); const weekValues = Object.values(total.weeks); this.xAxisStart = weekValues[0].week; this.xAxisEnd = firstStartDateAfterDate(new Date()); const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd)); total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks); this.xAxisMin = this.xAxisStart; this.xAxisMax = this.xAxisEnd; this.contributorsStats = {}; for (const [email, user] of Object.entries(rest)) { user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks); this.contributorsStats[email] = user; } this.sortContributors(); this.totalStats = total; this.errorText = ''; } else { this.errorText = response.statusText; } } catch (err) { this.errorText = err.message; } finally { this.isLoading = false; } }, filterContributorWeeksByDateRange() { const filteredData = {}; const data = this.contributorsStats; for (const key of Object.keys(data)) { const user = data[key]; user.total_commits = 0; user.total_additions = 0; user.total_deletions = 0; user.max_contribution_type = 0; const filteredWeeks = user.weeks.filter((week) => { const oneWeek = 7 * 24 * 60 * 60 * 1000; if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) { user.total_commits += week.commits; user.total_additions += week.additions; user.total_deletions += week.deletions; if (week[this.type] > user.max_contribution_type) { user.max_contribution_type = week[this.type]; } return true; } return false; }); // this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722 // for details. user.max_contribution_type += 1; filteredData[key] = {...user, weeks: filteredWeeks}; } return filteredData; }, maxMainGraph() { // This method calculates maximum value for Y value of the main graph. If the number // of maximum contributions for selected contribution type is 15.955 it is probably // better to round it up to 20.000.This method is responsible for doing that. // Normally, chartjs handles this automatically, but it will resize the graph when you // zoom, pan etc. I think resizing the graph makes it harder to compare things visually. const maxValue = Math.max( ...this.totalStats.weeks.map((o) => o[this.type]), ); const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); if (coefficient % 1 === 0) return maxValue; return (1 - (coefficient % 1)) * 10 ** exp + maxValue; }, maxContributorGraph() { // Similar to maxMainGraph method this method calculates maximum value for Y value // for contributors' graph. If I let chartjs do this for me, it will choose different // maxY value for each contributors' graph which again makes it harder to compare. const maxValue = Math.max( ...this.sortedContributors.map((c) => c.max_contribution_type), ); const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); if (coefficient % 1 === 0) return maxValue; return (1 - (coefficient % 1)) * 10 ** exp + maxValue; }, toGraphData(data) { return { datasets: [ { data: data.map((i) => ({x: i.week, y: i[this.type]})), pointRadius: 0, pointHitRadius: 0, fill: 'start', backgroundColor: chartJsColors[this.type], borderWidth: 0, tension: 0.3, }, ], }; }, updateOtherCharts(event, reset) { const minVal = event.chart.options.scales.x.min; const maxVal = event.chart.options.scales.x.max; if (reset) { this.xAxisMin = this.xAxisStart; this.xAxisMax = this.xAxisEnd; this.sortContributors(); } else if (minVal) { this.xAxisMin = minVal; this.xAxisMax = maxVal; this.sortContributors(); } }, getOptions(type) { return { responsive: true, maintainAspectRatio: false, animation: false, events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'], plugins: { title: { display: type === 'main', text: 'drag: zoom, shift+drag: pan, double click: reset zoom', position: 'top', align: 'center', }, customEventListener: { chartType: type, instance: this, }, zoom: { pan: { enabled: true, modifierKey: 'shift', mode: 'x', threshold: 20, onPanComplete: this.updateOtherCharts, }, limits: { x: { // Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits // to know what each option means min: 'original', max: 'original', // number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph minRange: 2 * 7 * 24 * 60 * 60 * 1000, }, }, zoom: { drag: { enabled: type === 'main', }, pinch: { enabled: type === 'main', }, mode: 'x', onZoomComplete: this.updateOtherCharts, }, }, }, scales: { x: { min: this.xAxisMin, max: this.xAxisMax, type: 'time', grid: { display: false, }, time: { minUnit: 'month', }, ticks: { maxRotation: 0, maxTicksLimit: type === 'main' ? 12 : 6, }, }, y: { min: 0, max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(), ticks: { maxTicksLimit: type === 'main' ? 6 : 4, }, }, }, }; }, }, }; </script> <template> <div> <div class="ui header tw-flex tw-items-center tw-justify-between"> <div> <relative-time v-if="xAxisMin > 0" format="datetime" year="numeric" month="short" day="numeric" weekday="" :datetime="new Date(xAxisMin)" > {{ new Date(xAxisMin) }} </relative-time> {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }} <relative-time v-if="xAxisMax > 0" format="datetime" year="numeric" month="short" day="numeric" weekday="" :datetime="new Date(xAxisMax)" > {{ new Date(xAxisMax) }} </relative-time> </div> <div> <!-- Contribution type --> <div class="ui dropdown jump" id="repo-contributors"> <div class="ui basic compact button"> <span class="text"> <span class="not-mobile">{{ locale.filterLabel }} </span><strong>{{ locale.contributionType[type] }}</strong> <svg-icon name="octicon-triangle-down" :size="14"/> </span> </div> <div class="menu"> <div :class="['item', {'active': type === 'commits'}]"> {{ locale.contributionType.commits }} </div> <div :class="['item', {'active': type === 'additions'}]"> {{ locale.contributionType.additions }} </div> <div :class="['item', {'active': type === 'deletions'}]"> {{ locale.contributionType.deletions }} </div> </div> </div> </div> </div> <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> <SvgIcon name="octicon-x-circle-fill"/> {{ errorText }} </div> </div> <ChartLine v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0" :data="toGraphData(totalStats.weeks)" :options="getOptions('main')" /> </div> <div class="contributor-grid"> <div v-for="(contributor, index) in sortedContributors" :key="index" v-memo="[sortedContributors, type]" > <div class="ui top attached header tw-flex tw-flex-1"> <b class="ui right">#{{ index + 1 }}</b> <a :href="contributor.home_link"> <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link"> </a> <div class="tw-ml-2"> <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> <h4 v-else class="contributor-name"> {{ contributor.name }} </h4> <p class="tw-text-12 tw-flex tw-gap-1"> <strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong> <strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong> <strong v-if="contributor.total_deletions" class="text red"> {{ contributor.total_deletions.toLocaleString() }}--</strong> </p> </div> </div> <div class="ui attached segment"> <div> <ChartLine :data="toGraphData(contributor.weeks)" :options="getOptions('contributor')" /> </div> </div> </div> </div> </div> </template> <style scoped> .main-graph { height: 260px; padding-top: 2px; } .contributor-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } .contributor-grid > * { min-width: 0; } @media (max-width: 991.98px) { .contributor-grid { grid-template-columns: repeat(1, 1fr); } } .contributor-name { margin-bottom: 0; } </style>