Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Repo Activity Racing Bar #696

Merged
merged 10 commits into from
Aug 13, 2023
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;
Comment on lines +108 to +113
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// @ts-ignore is like xxx: any, we should fix them all one day :D


// 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,
});
};
Comment on lines +115 to +121
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

microsoft/vscode has a lot of contributors, which slows down the animation when played at ~2018 or so.

image

notMerge: true is the best resolution I can give for now.


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();
Comment on lines +129 to +137
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another way to play

};

/**
* 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed there are many directive in this part, I think it would be better if we could remove some of these. The @ts-ignore could be convenient but there are too many of them which may ignore type errors. Please confirm they are necessary. Thanks

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, @ts-ignore and any should be avoided with best effort. In my commits, I removed some of them, but still left some others. My time is limited🤣 .

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have Andy fixing this later as a patch. I will merge this feature anyway but you can still improve your work. @andyhuang18 What do you say?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

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);
}
Comment on lines +187 to +190
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timer should be cleared.

};
}, []);

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
Loading