123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- <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-content-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="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"
- 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="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 tw-flex 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;
- 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>
|