forgejo/web_src/js/components/RepoContributors.vue
Şahin Akkaya e9be8b25ae
Implement contributors graph (#27882)
Continuation of https://github.com/go-gitea/gitea/pull/25439. Fixes #847

Before:
<img width="1296" alt="image"
src="https://github.com/go-gitea/gitea/assets/32161460/24571ac8-b254-43c9-b178-97340f0dc8a9">

----
After:
<img width="1296" alt="image"
src="https://github.com/go-gitea/gitea/assets/32161460/c60b2459-9d10-4d42-8d83-d5ef0f45bf94">

---
#### Overview
This is the implementation of a requested feature: Contributors graph
(#847)

It makes Activity page a multi-tab page and adds a new tab called
Contributors. Contributors tab shows the contribution graphs over time
since the repository existed. It also shows per user contribution graphs
for top 100 contributors. Top 100 is calculated based on the selected
contribution type (commits, additions or deletions).

---
#### Demo
(The demo is a bit old but still a good example to show off the main
features)

<video src="https://github.com/go-gitea/gitea/assets/32161460/9f68103f-8145-4cc2-94bc-5546daae7014" controls width="320" height="240">
  <a href="https://github.com/go-gitea/gitea/assets/32161460/9f68103f-8145-4cc2-94bc-5546daae7014">Download</a>
</video>

#### Features:

- Select contribution type (commits, additions or deletions)
- See overall and per user contribution graphs for the selected
contribution type
- Zoom and pan on graphs to see them in detail
- See top 100 contributors based on the selected contribution type and
selected time range
- Go directly to users' profile by clicking their name if they are
registered gitea users
- Cache the results so that when the same repository is visited again
fetching data will be faster

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: hiifong <i@hiif.ong>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: yp05327 <576951401@qq.com>
(cherry picked from commit 21331be30cb8f6c2d8b9dd99f1061623900632b9)
2024-02-17 23:24:31 +01:00

444 lines
14 KiB
Vue

<script>
import {SvgIcon} from '../svg.js';
import {
Chart,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
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 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import $ from 'jquery';
const {pageData} = window.config;
const colors = {
text: '--color-text',
border: '--color-secondary-alpha-60',
commits: '--color-primary-alpha-60',
additions: '--color-green',
deletions: '--color-red',
title: '--color-secondary-dark-4',
};
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => styles.getPropertyValue(name).trim();
for (const [key, value] of Object.entries(colors)) {
colors[key] = getColor(value);
}
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 = colors.text;
Chart.defaults.borderColor = colors.border;
Chart.register(
TimeScale,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
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 new Promise((resolve) => setTimeout(resolve, 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: colors[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',
color: colors.title,
position: 'top',
align: 'center',
},
customEventListener: {
chartType: type,
instance: this,
},
legend: {
display: false,
},
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>
<h2 class="ui header gt-df gt-ac gt-sb">
<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">
{{ locale.filterLabel }} <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>
</h2>
<div class="gt-df ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
<div v-if="isLoading">
<SvgIcon name="octicon-sync" class="gt-mr-3 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" class="stats-table"
v-memo="[sortedContributors, type]"
>
<div class="ui top attached header gt-df gt-f1">
<b class="ui right">#{{ index + 1 }}</b>
<a :href="contributor.home_link">
<img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link">
</a>
<div class="gt-ml-3">
<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="gt-font-12 gt-df gt-gap-2">
<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;
}
.contributor-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.contributor-name {
margin-bottom: 0;
}
</style>