2024-02-16 01:21:13 +03:00
< 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' ;
2024-02-24 02:41:24 +03:00
import { chartJsColors } from '../utils/color.js' ;
import { sleep } from '../utils.js' ;
2024-02-16 01:21:13 +03:00
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 ) ;
}
}
} ;
2024-02-24 02:41:24 +03:00
Chart . defaults . color = chartJsColors . text ;
Chart . defaults . borderColor = chartJsColors . border ;
2024-02-16 01:21:13 +03:00
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 ) {
2024-02-24 02:41:24 +03:00
await sleep ( 1000 ) ; // wait for 1 second before retrying
2024-02-16 01:21:13 +03:00
}
} 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' ,
2024-02-24 02:41:24 +03:00
backgroundColor : chartJsColors [ this . type ] ,
2024-02-16 01:21:13 +03:00
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 >
< 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 ) } }
< / r e l a t i v e - t i m e >
{ { 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 ) } }
< / r e l a t i v e - t i m e >
< / 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 : 260 px ;
}
. contributor - grid {
display : grid ;
grid - template - columns : repeat ( 2 , 1 fr ) ;
gap : 1 rem ;
}
. contributor - name {
margin - bottom : 0 ;
}
< / style >