diff --git a/.gitignore b/.gitignore index 4c772139..e492d8d4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ .env.production.local .history .vscode +.idea package-lock.json # secrets diff --git a/src/api/repo.ts b/src/api/repo.ts index 5be9f536..63858546 100644 --- a/src/api/repo.ts +++ b/src/api/repo.ts @@ -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) => { @@ -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'); +}; diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index 77ef9ea2..81136a96 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -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." }, diff --git a/src/locales/zh_CN/messages.json b/src/locales/zh_CN/messages.json index 44d8076e..95a2e690 100644 --- a/src/locales/zh_CN/messages.json +++ b/src/locales/zh_CN/messages.json @@ -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": "项目关系网络图展示了在给定的时间段内,项目与项目之间的联结关系,用于项目间关系的追踪与挖掘。从该网络图中,可以找出与该项目有联结关系的其他项目。" }, diff --git a/src/pages/ContentScripts/features/repo-activity-racing-bar/RacingBar.tsx b/src/pages/ContentScripts/features/repo-activity-racing-bar/RacingBar.tsx new file mode 100644 index 00000000..ec3961aa --- /dev/null +++ b/src/pages/ContentScripts/features/repo-activity-racing-bar/RacingBar.tsx @@ -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(); + 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(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 ( +
+
+
+ ); +}; + +export default RacingBar; diff --git a/src/pages/ContentScripts/features/repo-activity-racing-bar/index.tsx b/src/pages/ContentScripts/features/repo-activity-racing-bar/index.tsx new file mode 100644 index 00000000..76bf23f5 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-activity-racing-bar/index.tsx @@ -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(, container); + return; + } + render( + , + container + ); +}; + +const init = async (): Promise => { + 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, +}); diff --git a/src/pages/ContentScripts/features/repo-activity-racing-bar/view.tsx b/src/pages/ContentScripts/features/repo-activity-racing-bar/view.tsx new file mode 100644 index 00000000..ce05b43f --- /dev/null +++ b/src/pages/ContentScripts/features/repo-activity-racing-bar/view.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; + +import getMessageByLocale from '../../../../helpers/get-message-by-locale'; +import optionsStorage, { + HypercrxOptions, + defaults, +} from '../../../../options-storage'; +import RacingBar from './RacingBar'; + +interface Props { + currentRepo: string; + repoActivityDetails: any; +} + +const View = ({ currentRepo, repoActivityDetails }: Props): JSX.Element => { + const [options, setOptions] = useState(defaults); + const [replay, setReplay] = useState(0); + + useEffect(() => { + (async function () { + setOptions(await optionsStorage.getAll()); + })(); + }, []); + + const handleReplayClick = () => { + setReplay(replay + 1); + }; + + return ( +
+
+
+ + {getMessageByLocale( + 'component_projectRacingBar_title', + options.locale + )} + +
+ +
+
+
+
+
+ +
+
+
+
+

+ {getMessageByLocale( + 'component_projectRacingBar_description', + options.locale + )} +

+
+
+
+
+
+ ); +}; + +export default View; diff --git a/src/pages/ContentScripts/index.scss b/src/pages/ContentScripts/index.scss index 3862433e..94d33c3e 100644 --- a/src/pages/ContentScripts/index.scss +++ b/src/pages/ContentScripts/index.scss @@ -109,3 +109,22 @@ -ms-user-select: none; /* IE10+/Edge */ user-select: none; /* Standard */ } + +.replay-button { + display: inline-block; + text-align: center; + text-decoration: none; + border-radius: 4px; + cursor: pointer; + padding: 3px 12px; + font-size: 12px; + line-height: 20px; + font-weight: var(--base-text-weight-medium, 500); + color: var(--button-default-fgColor-rest, var(--color-btn-text)); + background-color: var(--button-default-bgColor-rest, var(--color-btn-bg)); + border-color: var(--button-default-borderColor-rest, var(--color-btn-border)); + box-shadow: var(--button-default-shadow-resting, var(--color-btn-shadow)), + var(--button-default-shadow-inset, var(--color-btn-inset-shadow)); + transition: 80ms cubic-bezier(0.33, 1, 0.68, 1); + transition-property: color, background-color, box-shadow, border-color; +} diff --git a/src/pages/ContentScripts/index.ts b/src/pages/ContentScripts/index.ts index 346f7331..bab274a3 100644 --- a/src/pages/ContentScripts/index.ts +++ b/src/pages/ContentScripts/index.ts @@ -12,3 +12,4 @@ import './features/perceptor-layout'; import './features/repo-networks'; import './features/developer-networks'; import './features/oss-gpt'; +import './features/repo-activity-racing-bar';