Skip to content

Commit

Permalink
feat: add Repo Activity Racing Bar (#696)
Browse files Browse the repository at this point in the history
* feat: add activity racing bar

feat: initial implementation of activity racing bar(need to be modified)

feat: initial implementation of activity racing bar(need to be modified v1)

feat: initial implementation of activity racing bar(need to be modified v2)

feat: implementation of activity racing bar

feat: change the style of chart and button

* feat: change chart width and put button to corner

* feat: support for i18n

* feat: update .gitignore for .idea files

* style: better style makes it look better

* chore: let and const has their own scope

* chore: better way to play and replay the racing bar

* chore: update messages

* feat: variable bar number according to contributors number

* fix: fix performance issue

---------

Co-authored-by: Lam Tang <[email protected]>
  • Loading branch information
andyhuang18 and tyn1998 authored Aug 13, 2023
1 parent 746d58b commit 2e5abf4
Show file tree
Hide file tree
Showing 9 changed files with 381 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
.env.production.local
.history
.vscode
.idea
package-lock.json

# secrets
Expand Down
5 changes: 5 additions & 0 deletions src/api/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const metricNameMap = new Map([
['merged_code_sum', 'code_change_lines_sum'],
['developer_network', 'developer_network'],
['repo_network', 'repo_network'],
['activity_details', 'activity_details'],
]);

export const getActivity = async (repo: string) => {
Expand Down Expand Up @@ -83,3 +84,7 @@ export const getDeveloperNetwork = async (repo: string) => {
export const getRepoNetwork = async (repo: string) => {
return getMetricByName(repo, metricNameMap, 'repo_network');
};

export const getActivityDetails = async (repo: string) => {
return getMetricByName(repo, metricNameMap, 'activity_details');
};
9 changes: 9 additions & 0 deletions src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,18 @@
"component_developerActORTrend_yName2": {
"message": "OpenRank"
},
"component_projectRacingBar_title": {
"message": "Contributor Activity Racing Bar"
},
"component_projectCorrelationNetwork_title": {
"message": "Project Correlation Network"
},
"component_projectRacingBar_ReplayButton": {
"message": "Replay"
},
"component_projectRacingBar_description": {
"message": "This chart shows how the activity values of contributors in this repository evolve."
},
"component_projectCorrelationNetwork_description": {
"message": "Project Correlation Network shows the correlation between projects for a given time period. From this graph you can find the projects that are related to the given project."
},
Expand Down
9 changes: 9 additions & 0 deletions src/locales/zh_CN/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,18 @@
"component_developerActORTrend_yName2": {
"message": "OpenRank"
},
"component_projectRacingBar_title": {
"message": "贡献者活跃度滚榜"
},
"component_projectCorrelationNetwork_title": {
"message": "项目关系网络图"
},
"component_projectRacingBar_ReplayButton": {
"message": "重播"
},
"component_projectRacingBar_description": {
"message": "贡献者活跃度滚榜展示了项目贡献者的活跃度演化过程。"
},
"component_projectCorrelationNetwork_description": {
"message": "项目关系网络图展示了在给定的时间段内,项目与项目之间的联结关系,用于项目间关系的追踪与挖掘。从该网络图中,可以找出与该项目有联结关系的其他项目。"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import React, { useEffect, useRef } from 'react';
import * as echarts from 'echarts';
import type { EChartsOption, EChartsType } from 'echarts';

interface RacingBarProps {
repoName: string;
data: any;
}

// TODO generate color from user avatar
const colorMap = new Map();

const updateFrequency = 3000;

const option: EChartsOption = {
grid: {
top: 10,
bottom: 30,
left: 150,
right: 50,
},
xAxis: {
max: 'dataMax',
},
yAxis: {
type: 'category',
inverse: true,
max: 10,
axisLabel: {
show: true,
fontSize: 14,
formatter: function (value: string) {
if (!value || value.endsWith('[bot]')) return value;
return `${value} {avatar${value.replaceAll('-', '')}|}`;
},
},
axisTick: {
show: false,
},
animationDuration: 0,
animationDurationUpdate: 200,
},
series: [
{
realtimeSort: true,
seriesLayoutBy: 'column',
type: 'bar',
itemStyle: {
color: function (params: any) {
const githubId = params.value[0];
if (colorMap.has(githubId)) {
return colorMap.get(githubId);
} else {
const randomColor =
'#' + Math.floor(Math.random() * 16777215).toString(16);
colorMap.set(githubId, randomColor);
return randomColor;
}
},
},
data: undefined,
encode: {
x: 1,
y: 0,
},
label: {
show: true,
precision: 1,
position: 'right',
valueAnimation: true,
fontFamily: 'monospace',
},
},
],
// Disable init animation.
animationDuration: 0,
animationDurationUpdate: updateFrequency,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
graphic: {
elements: [
{
type: 'text',
right: 60,
bottom: 60,
style: {
text: undefined,
font: 'bolder 60px monospace',
fill: 'rgba(100, 100, 100, 0.25)',
},
z: 100,
},
],
},
};

const updateMonth = (instance: EChartsType, data: any, month: string) => {
const rich: any = {};
data[month].forEach((item: any[]) => {
// rich name cannot contain special characters such as '-'
rich[`avatar${item[0].replaceAll('-', '')}`] = {
backgroundColor: {
image: `https://avatars.githubusercontent.com/${item[0]}?s=48&v=4`,
},
height: 20,
};
});
// @ts-ignore
option.yAxis.axisLabel.rich = rich;
// @ts-ignore
option.series[0].data = data[month];
// @ts-ignore
option.graphic.elements[0].style.text = month;

// it seems that hidden bars are also rendered, so when each setOption merge more data into the chart,
// the fps goes down. So we use notMerge to avoid merging data. But this disables the xAxis animation.
// Hope we can find a better solution.
instance.setOption(option, {
notMerge: true,
});
};

let timer: NodeJS.Timeout;

const play = (instance: EChartsType, data: any) => {
const months = Object.keys(data);
let i = 0;

const playNext = () => {
updateMonth(instance, data, months[i]);
i++;
if (i < months.length) {
timer = setTimeout(playNext, updateFrequency);
}
};

playNext();
};

/**
* Count the number of unique contributors in the data
*/
const countLongTermContributors = (data: any) => {
const contributors = new Map<string, number>();
Object.keys(data).forEach((month) => {
data[month].forEach((item: any[]) => {
if (contributors.has(item[0])) {
contributors.set(item[0], contributors.get(item[0])! + 1);
} else {
contributors.set(item[0], 0);
}
});
});
let count = 0;
contributors.forEach((value) => {
// only count contributors who have contributed more than 3 months
if (value >= 3) {
count++;
}
});
return count;
};

const RacingBar = ({ data }: RacingBarProps): JSX.Element => {
const divEL = useRef<HTMLDivElement>(null);

let height = 300;
const longTermContributorsCount = countLongTermContributors(data);
if (longTermContributorsCount >= 20) {
// @ts-ignore
option.yAxis.max = 20;
height = 600;
}

useEffect(() => {
if (!divEL.current) return;

const chartDOM = divEL.current;
const instance = echarts.init(chartDOM);

play(instance, data);

return () => {
if (!instance.isDisposed()) {
instance.dispose();
}
// clear timer if user replay the chart before it finishes
if (timer) {
clearTimeout(timer);
}
};
}, []);

return (
<div className="hypertrons-crx-border">
<div ref={divEL} style={{ width: '100%', height }}></div>
</div>
);
};

export default RacingBar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { render, Container } from 'react-dom';
import $ from 'jquery';

import features from '../../../../feature-manager';
import isPerceptor from '../../../../helpers/is-perceptor';
import { getRepoName } from '../../../../helpers/get-repo-info';
import { getActivityDetails } from '../../../../api/repo';
import View from './view';
import DataNotFound from '../repo-networks/DataNotFound';
import * as pageDetect from 'github-url-detection';

const featureId = features.getFeatureID(import.meta.url);
let repoName: string;
let repoActivityDetails: any;

const getData = async () => {
repoActivityDetails = await getActivityDetails(repoName);
};

const renderTo = (container: Container) => {
if (!repoActivityDetails) {
render(<DataNotFound />, container);
return;
}
render(
<View currentRepo={repoName} repoActivityDetails={repoActivityDetails} />,
container
);
};

const init = async (): Promise<void> => {
repoName = getRepoName();
await getData();
const container = document.createElement('div');
container.id = featureId;
renderTo(container);
const parentElement = document.getElementById('hypercrx-perceptor-layout');
if (parentElement) {
parentElement.append(container);
}
};

const restore = async () => {
// Clicking another repo link in one repo will trigger a turbo:visit,
// so in a restoration visit we should be careful of the current repo.
if (repoName !== getRepoName()) {
repoName = getRepoName();
}
// rerender the chart or it will be empty
renderTo($(`#${featureId}`)[0]);
};

features.add(featureId, {
asLongAs: [isPerceptor],
awaitDomReady: false,
init,
restore,
});
Loading

0 comments on commit 2e5abf4

Please sign in to comment.