diff --git a/package.json b/package.json index 6c75d6df..07c6b396 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-hot-loader": "^4.13.0", "react-i18next": "^14.1.2", "react-modal": "3.15.1", + "rsuite": "^5.67.0", "strip-indent": "^4.0.0" }, "devDependencies": { diff --git a/src/api/community.ts b/src/api/community.ts new file mode 100644 index 00000000..fd2ecb6d --- /dev/null +++ b/src/api/community.ts @@ -0,0 +1,24 @@ +import request from '../helpers/request'; +import { ErrorCode, OSS_XLAB_ENDPOINT } from '../constant'; + +export const getMetricByDate = async (repoName: string, date: string) => { + let response; + try { + response = await request( + `${OSS_XLAB_ENDPOINT}/open_digger/github/${repoName}/project_openrank_detail/${date}.json` + ); + } catch (error) { + // the catched error being "404" means the metric file is not available so return a null + if (error === ErrorCode.NOT_FOUND) { + return null; + } else { + // other errors should be throwed + throw error; + } + } + return response; +}; + +export const getOpenRank = async (repo: string, date: string) => { + return getMetricByDate(repo, date); +}; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index cf9b6f6a..8b8aae1d 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -3,6 +3,10 @@ "global_day_one": "day", "global_day_other": "days", "global_clickToshow": "Click to show", + "component_communityOpenRankNetwork_title": "Community OpenRank Detail Network", + "component_communityOpenRankNetwork_description": "Community OpenRank Detail Network shows the OpenRank about the project by month. Double-click a node in this network to view the details of OpenRank for that node in the table on the right. ", + "component_communityOpenRankRacingBar_title": "Community OpenRank Racing Bar", + "component_communityOpenRankRacingBar_description": "This chart shows how the OpenRank in this community evolve. ", "component_developerCollaborationNetwork_title": "Developer Collaboration Network", "component_developerCollaborationNetwork_description": "Developer Collaboration Network shows the collaboration between developers for a given time period. From this graph you can find other developers who are closet to a given developer.", "component_developerCollaborationNetwork_description_node": "Node: Developer, node size and shades of color indicate developer activity.", diff --git a/src/locales/zh_CN/translation.json b/src/locales/zh_CN/translation.json index f4181599..fc370c78 100644 --- a/src/locales/zh_CN/translation.json +++ b/src/locales/zh_CN/translation.json @@ -2,6 +2,10 @@ "global_period": "周期", "global_day": "天", "global_clickToshow": "点击查看", + "component_communityOpenRankNetwork_title": "社区OpenRank网络图", + "component_communityOpenRankNetwork_description": "社区OpenRank网络图按月显示有关该项目的OpenRank。双击此网络中的一个节点,可以在右侧表格中查看该节点OpenRank详细信息。 ", + "component_communityOpenRankRacingBar_title": "社区OpenRank滚榜", + "component_communityOpenRankRacingBar_description": "社区OpenRank滚榜展示了社区中OpenRank的演化过程。", "component_developerCollaborationNetwork_title": "开发者协作网络图", "component_developerCollaborationNetwork_description": "开发者协作网络图展示了在给定的时间段内,开发者与开发者之间的协作关系, 用于开发者关系的追踪与挖掘。从该网络图中,可以找出与该开发者联系较为紧密的其他开发者。", "component_developerCollaborationNetwork_description_node": "节点:一个节点表示开发者,节点大小与颜色的深浅表示开发者活跃度的大小。", diff --git a/src/pages/ContentScripts/features/community-openrank-network/Network.tsx b/src/pages/ContentScripts/features/community-openrank-network/Network.tsx new file mode 100644 index 00000000..b9430c89 --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-network/Network.tsx @@ -0,0 +1,252 @@ +import React, { CSSProperties, forwardRef, useEffect, useRef, ForwardedRef, useImperativeHandle } from 'react'; +import * as echarts from 'echarts'; + +import { debounce } from 'lodash-es'; +import getGithubTheme from '../../../../helpers/get-github-theme'; +import { getOpenRank } from '../../../../api/community'; + +export interface DateControllers { + update: (newDate: string) => void; +} + +interface NetworkProps { + /** + * data + */ + readonly data: any; + /** + * `style` for graph container + */ + readonly style?: CSSProperties; + + readonly focusedNodeID: string; + + date?: string; +} + +const typeMap = new Map([ + ['r', 'repo'], + ['i', 'issue'], + ['p', 'pull'], + ['u', 'user'], +]); + +const genName = (node: { c: string; n: { toString: () => any } }) => + node.c == 'i' || node.c == 'p' ? `#${node.n.toString()}` : node.n.toString(); + +const categories = Array.from(typeMap.values()); + +const theme = getGithubTheme(); +const DARK_TEXT_COLOR = 'rgba(230, 237, 243, 0.9)'; + +const generateEchartsData = (data: any, focusedNodeID: string | undefined): any => { + const generateNodes = (nodes: any[]): any => { + return nodes.map((n: any) => { + return { + id: n.id, + name: genName(n), + value: n.v, + symbolSize: Math.log(n.v + 1) * 6, + category: typeMap.get(n.c), + }; + }); + }; + const generateEdges = (edges: any[]): any => { + if (edges.length === 0) { + return []; + } + return edges.map((e: any) => { + return { + source: e.s, + target: e.t, + value: e.w, + }; + }); + }; + return { + nodes: generateNodes(data.nodes), + edges: generateEdges(data.links), + }; +}; + +const getOption = (data: any, date: string | undefined) => { + return { + tooltip: { + trigger: 'item', + }, + animation: true, + animationDuration: 2000, + + legend: [ + { + data: categories, + }, + ], + series: [ + { + name: 'Collaborative graph', + type: 'graph', + layout: 'force', + nodes: data.nodes, + edges: data.edges, + categories: categories.map((c) => { + return { name: c }; + }), + // Enable mouse zooming and translating + roam: true, + label: { + position: 'right', + show: true, + }, + force: { + repulsion: 300, + // Disable the iteration animation of layout + layoutAnimation: false, + }, + lineStyle: { + curveness: 0.3, + opacity: 0.2, + }, + emphasis: { + focus: 'adjacency', + label: { + position: 'right', + show: true, + }, + }, + }, + ], + graphic: { + elements: [ + { + type: 'text', + right: 60, + bottom: 60, + style: { + text: date, + font: 'bolder 60px monospace', + fill: theme === 'light' ? 'rgba(100, 100, 100, 0.3)' : DARK_TEXT_COLOR, + }, + z: 100, + }, + ], + }, + }; +}; + +const Network = forwardRef( + ( + { data, style = {}, focusedNodeID, date }: NetworkProps, + forwardedRef: ForwardedRef + ): JSX.Element => { + const divEL = useRef(null); + let graphData = generateEchartsData(data, focusedNodeID); + let option = getOption(graphData, date); + + const clearDiv = (id: string) => { + var div = document.getElementById(id); + if (div && div.hasChildNodes()) { + var children = div.childNodes; + for (var child of children) { + div.removeChild(child); + } + } + }; + + const addRow = (table: HTMLElement | null, texts: any[]) => { + // @ts-ignore + var tr = table.insertRow(); + for (var t of texts) { + var td = tr.insertCell(); + td.appendChild(document.createTextNode(t)); + } + }; + + const update = (newDate: string) => { + getOpenRank(focusedNodeID, newDate).then((openRank) => { + let chartDOM = divEL.current; + const instance = echarts.getInstanceByDom(chartDOM as any); + if (instance) { + if (openRank == null) { + instance.setOption( + { + title: { + text: `OpenRank for ${focusedNodeID} in ${newDate} is has not been generated`, + top: 'middle', + left: 'center', + }, + }, + { notMerge: true } + ); + } else { + graphData = generateEchartsData(openRank, focusedNodeID); + option = getOption(graphData, newDate); + instance.setOption(option, { notMerge: true }); + } + } + }); + }; + + const setDetails = (graph: { links: any[]; nodes: any[] }, node: { r: number; i: number; id: any }) => { + clearDiv('details_table'); + var table = document.getElementById('details_table'); + addRow(table, ['From', 'Ratio', 'Value', 'OpenRank']); + addRow(table, ['Self', node.r, node.i, (node.r * node.i).toFixed(3)]); + var other = graph.links + .filter((l) => l.t == node.id) + .map((l) => { + var source = graph.nodes.find((n) => n.id == l.s); + return [ + genName(source), + parseFloat(((1 - node.r) * l.w).toFixed(3)), + source.v, + parseFloat(((1 - node.r) * l.w * source.v).toFixed(3)), + ]; + }) + .sort((a, b) => b[3] - a[3]); + for (var r of other) { + addRow(table, r); + } + }; + + useImperativeHandle(forwardedRef, () => ({ + update, + })); + + useEffect(() => { + let chartDOM = divEL.current; + const instance = echarts.init(chartDOM as any); + + return () => { + instance.dispose(); + }; + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const instance = echarts.getInstanceByDom(chartDOM as any); + if (instance) { + instance.setOption(option); + instance.on('dblclick', function (params) { + setDetails( + data, + // @ts-ignore + data.nodes.find((i: { id: any }) => i.id === params.data.id) + ); + }); + const debouncedResize = debounce(() => { + instance.resize(); + }, 1000); + window.addEventListener('resize', debouncedResize); + } + }, []); + + return ( +
+
+
+ ); + } +); + +export default Network; diff --git a/src/pages/ContentScripts/features/community-openrank-network/index.scss b/src/pages/ContentScripts/features/community-openrank-network/index.scss new file mode 100644 index 00000000..efb35b0b --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-network/index.scss @@ -0,0 +1,31 @@ +#details_table { + width: 95%; + margin: 10px; + tr:nth-child(even) { + background-color: #d6eeee; + } + th, + td { + border: 1px solid black; + text-align: center; + vertical-align: middle; + } +} + +#details_title { + text-align: center; + font-size: 12px; +} + +.bordered { + border: 2px solid grey; +} + +#details_div { + height: 250px; +} + +.scrollit { + overflow-x: hidden; + overflow-y: auto; +} diff --git a/src/pages/ContentScripts/features/community-openrank-network/index.tsx b/src/pages/ContentScripts/features/community-openrank-network/index.tsx new file mode 100644 index 00000000..7f88c309 --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-network/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, isPublicRepoWithMeta } from '../../../../helpers/get-repo-info'; +import { getOpenRank } from '../../../../api/community'; +import { RepoMeta, metaStore } from '../../../../api/common'; +import View from './view'; +import './index.scss'; +import DataNotFound from '../repo-networks/DataNotFound'; + +const featureId = features.getFeatureID(import.meta.url); +let repoName: string; +let openRank: any; +let meta: RepoMeta; + +const getData = async () => { + meta = (await metaStore.get(repoName)) as RepoMeta; + openRank = await getOpenRank(repoName, '2023-09'); +}; + +const renderTo = (container: Container) => { + if (!openRank) { + render(, container); + return; + } + render(, container); +}; + +const init = async (): Promise => { + repoName = getRepoName(); + await getData(); + // create container + const container = document.createElement('div'); + container.id = featureId; + + $('#hypercrx-perceptor-slot-community-openrank-network').append(container); + renderTo(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(); + await getData(); + } + // rerender the chart or it will be empty + renderTo($(`#${featureId}`)[0]); +}; + +features.add(featureId, { + asLongAs: [isPerceptor, isPublicRepoWithMeta], + awaitDomReady: true, + init, + restore, +}); diff --git a/src/pages/ContentScripts/features/community-openrank-network/view.tsx b/src/pages/ContentScripts/features/community-openrank-network/view.tsx new file mode 100644 index 00000000..6325ae7f --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-network/view.tsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect, useRef } from 'react'; + +import optionsStorage, { HypercrxOptions, defaults } from '../../../../options-storage'; +import { RepoMeta } from '../../../../api/common'; +import { t } from 'i18next'; +import Network, { DateControllers } from './Network'; +import { DatePicker } from 'antd'; +import dayjs from 'dayjs'; + +interface Props { + repoName: string; + openrank: any; + meta: RepoMeta; +} + +const graphStyle = { + width: '100%', + height: '380px', +}; + +const View = ({ repoName, openrank, meta }: Props): JSX.Element | null => { + const [options, setOptions] = useState(defaults); + const dateControllersRef = useRef(null); + + useEffect(() => { + (async function () { + setOptions(await optionsStorage.getAll()); + })(); + }, []); + + const onChange = (newDate: dayjs.Dayjs) => { + let date = newDate.format('YYYY-MM'); + dateControllersRef.current?.update(date); + }; + + if (!openrank) return null; + + return ( +
+
+
+ {t('component_communityOpenRankNetwork_title')} +
+ +
+
+
+
+
+ +
+
+
+
+

{t('component_communityOpenRankNetwork_description')}

+
+
+

Details

+
+
+
+
+
+
+
+
+
+
+ ); +}; + +export default View; diff --git a/src/pages/ContentScripts/features/community-openrank-racing-bar/RacingBar.tsx b/src/pages/ContentScripts/features/community-openrank-racing-bar/RacingBar.tsx new file mode 100644 index 00000000..cec4d350 --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-racing-bar/RacingBar.tsx @@ -0,0 +1,174 @@ +import { CommunityOpenRankDetails, getOption, countLongTermItems, DEFAULT_FREQUENCY } from './data'; +import sleep from '../../../../helpers/sleep'; + +import React, { useEffect, useRef, forwardRef, useImperativeHandle, ForwardedRef } from 'react'; +import * as echarts from 'echarts'; +import type { EChartsType } from 'echarts'; + +export interface MediaControllers { + play: () => void; + pause: () => void; + next: () => void; + previous: () => void; + latest: () => void; + earliest: () => void; + updateType: (type: string) => void; +} + +interface RacingBarProps { + speed: number; + data: CommunityOpenRankDetails; + setPlaying: (playing: boolean) => void; +} + +const RacingBar = forwardRef( + ({ speed, data, setPlaying }: RacingBarProps, forwardedRef: ForwardedRef): JSX.Element => { + const divEL = useRef(null); + const timerRef = useRef(); + const speedRef = useRef(speed); + const openRankRef = useRef(data); + speedRef.current = speed; + + const monthsRef = useRef(Object.keys(openRankRef.current)); + const monthIndexRef = useRef(monthsRef.current.length - 1); + + let longTermItemsCount = countLongTermItems(openRankRef.current); + + const maxBarsRef = useRef(longTermItemsCount >= 20 ? 20 : 10); + const heightRef = useRef(longTermItemsCount >= 20 ? 600 : 300); + + const updateMonth = async (instance: EChartsType, month: string, enableAnimation: boolean) => { + const option = await getOption(openRankRef.current, month, speedRef.current, maxBarsRef.current, enableAnimation); + instance.setOption(option); + }; + + const play = async () => { + const nextMonth = async () => { + monthIndexRef.current++; + const instance = echarts.getInstanceByDom(divEL.current!)!; + updateMonth(instance, monthsRef.current[monthIndexRef.current], true); + if (monthIndexRef.current < monthsRef.current.length - 1) { + timerRef.current = setTimeout(nextMonth, DEFAULT_FREQUENCY / speedRef.current); + } else { + setTimeout(() => { + setPlaying(false); + }, DEFAULT_FREQUENCY / speedRef.current); + } + }; + + setPlaying(true); + // if the current month is the latest month, go to the beginning + if (monthIndexRef.current === monthsRef.current.length - 1) { + earliest(); + await sleep(DEFAULT_FREQUENCY / speedRef.current); + } + nextMonth(); + }; + + const pause = () => { + setPlaying(false); + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + + const next = () => { + pause(); + if (monthIndexRef.current < monthsRef.current.length - 1) { + const instance = echarts.getInstanceByDom(divEL.current!)!; + monthIndexRef.current++; + updateMonth(instance, monthsRef.current[monthIndexRef.current], false); + } + }; + + const previous = () => { + pause(); + if (monthIndexRef.current > 0) { + const instance = echarts.getInstanceByDom(divEL.current!)!; + monthIndexRef.current--; + updateMonth(instance, monthsRef.current[monthIndexRef.current], false); + } + }; + + const latest = () => { + pause(); + const instance = echarts.getInstanceByDom(divEL.current!)!; + monthIndexRef.current = monthsRef.current.length - 1; + updateMonth(instance, monthsRef.current[monthIndexRef.current], false); + }; + + const earliest = () => { + const instance = echarts.getInstanceByDom(divEL.current!)!; + monthIndexRef.current = 0; + updateMonth(instance, monthsRef.current[monthIndexRef.current], false); + }; + + const getOpenRankByType = (data: CommunityOpenRankDetails, type: string): CommunityOpenRankDetails => { + if (type === 'a') { + return data; + } + const filteredData: CommunityOpenRankDetails = {}; + + for (const [date, nodes] of Object.entries(data)) { + let typedData = nodes.filter(([_, c]) => c === type); + if (typedData.length != 0) { + filteredData[date] = typedData; + } + } + return filteredData; + }; + + const updateType = (type: string) => { + openRankRef.current = getOpenRankByType(data, type); + monthsRef.current = Object.keys(openRankRef.current); + monthIndexRef.current = monthsRef.current.length - 1; + + getOption( + openRankRef.current, + monthsRef.current[monthIndexRef.current], + speedRef.current, + maxBarsRef.current, + false + ).then((newOption) => { + const instance = echarts.getInstanceByDom(divEL.current!)!; + instance.setOption(newOption); + }); + }; + + // expose startRecording and stopRecording to parent component + useImperativeHandle(forwardedRef, () => ({ + play, + pause, + next, + previous, + latest, + earliest, + updateType, + })); + + useEffect(() => { + (async () => { + const instance = echarts.init(divEL.current!); + updateMonth(instance, monthsRef.current[monthIndexRef.current], false); + })(); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + const instance = echarts.getInstanceByDom(divEL.current!); + if (instance && !instance.isDisposed()) { + instance.dispose(); + } + }; + }, []); + + return ( +
+
+
+ ); + } +); + +export default RacingBar; diff --git a/src/pages/ContentScripts/features/community-openrank-racing-bar/data.ts b/src/pages/ContentScripts/features/community-openrank-racing-bar/data.ts new file mode 100644 index 00000000..2195d8ca --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-racing-bar/data.ts @@ -0,0 +1,180 @@ +import getGithubTheme from '../../../../helpers/get-github-theme'; + +import type { BarSeriesOption, EChartsOption } from 'echarts'; +import { orderBy, take } from 'lodash-es'; + +const theme = getGithubTheme(); +const DARK_TEXT_COLOR = 'rgba(230, 237, 243, 0.9)'; + +export interface CommunityOpenRankDetails { + // e.g. 2020-05: [["frank-zsy", 4.69], ["heming6666", 3.46], ["menbotics[bot]", 2]] + [key: string]: [string, string, number][]; +} + +/** + * Filter and extract monthly data from the given data structure, which includes various date formats such as "yyyy", "yyyy-Qq", and "yyyy-mm". + * This function extracts and returns only the monthly data in the "yyyy-mm" format. + * + * @returns CommunityOpenRankDetails + * @param data + */ +export function getMonthlyData(data: CommunityOpenRankDetails) { + const monthlyData: CommunityOpenRankDetails = {}; + + for (const key in data) { + // Check if the key matches the yyyy-mm format (e.g., "2020-05") + if (/^\d{4}-\d{2}$/.test(key)) { + monthlyData[key] = data[key]; + } + } + return monthlyData; +} + +/** + * Count the number of unique items in the data + * @returns [number of long term items, items' names] + */ +export const countLongTermItems = (data: CommunityOpenRankDetails): number => { + const map = new Map(); + Object.keys(data).forEach((month) => { + data[month].forEach((item) => { + if (map.has(item[0])) { + map.set(item[0], map.get(item[0])! + 1); + } else { + map.set(item[0], 0); + } + }); + }); + let count = 0; + map.forEach((value) => { + // only count map who have contributed more than 3 months + if (value >= 3) { + count++; + } + }); + return count; +}; + +export const DEFAULT_FREQUENCY = 2000; + +/** + * get the echarts option with the given data, month and speed. + */ +export const getOption = async ( + data: CommunityOpenRankDetails, + month: string, + speed: number, + maxBars: number, + enableAnimation: boolean +): Promise => { + const updateFrequency = DEFAULT_FREQUENCY / speed; + const rich: any = {}; + const sortedData = orderBy(data[month], (item) => item[2], 'desc'); + const topData = take(sortedData, maxBars); + const colorMap = new Map([ + ['r', '#72a8d6'], + ['i', '#98F8DD'], + ['p', '#F2FF7F'], + ['u', '#f8a8ab'], + ]); + const barData: BarSeriesOption['data'] = await Promise.all( + topData.map(async (item) => { + let color = colorMap.get(item[1]); + return { + value: [item[0], item[2]], + itemStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 1, + y2: 0, + colorStops: [ + { + offset: 0, + color: 'white', + }, + { + offset: 0.5, + color: color, + }, + ], + global: false, + }, + }, + }; + }) + ); + + return { + grid: { + top: 10, + bottom: 30, + left: 160, + right: 50, + }, + xAxis: { + max: 'dataMax', + axisLabel: { + show: true, + color: theme === 'light' ? undefined : DARK_TEXT_COLOR, + }, + }, + yAxis: { + type: 'category', + inverse: true, + max: maxBars, + axisLabel: { + show: true, + color: theme === 'light' ? undefined : DARK_TEXT_COLOR, + fontSize: 14, + rich, + }, + axisTick: { + show: false, + }, + animationDuration: 0, + animationDurationUpdate: enableAnimation ? 200 : 0, + }, + series: [ + { + realtimeSort: true, + seriesLayoutBy: 'column', + type: 'bar', + data: barData, + encode: { + x: 1, + y: 0, + }, + label: { + show: true, + precision: 1, + position: 'right', + valueAnimation: true, + fontFamily: 'monospace', + color: theme === 'light' ? undefined : DARK_TEXT_COLOR, + }, + }, + ], + // Disable init animation. + animationDuration: 0, + animationDurationUpdate: enableAnimation ? updateFrequency : 0, + animationEasing: 'linear', + animationEasingUpdate: 'linear', + graphic: { + elements: [ + { + type: 'text', + right: 60, + bottom: 60, + style: { + text: month, + font: 'bolder 60px monospace', + fill: theme === 'light' ? 'rgba(100, 100, 100, 0.3)' : DARK_TEXT_COLOR, + }, + z: 100, + }, + ], + }, + }; +}; diff --git a/src/pages/ContentScripts/features/community-openrank-racing-bar/index.tsx b/src/pages/ContentScripts/features/community-openrank-racing-bar/index.tsx new file mode 100644 index 00000000..fc0e8907 --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-racing-bar/index.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Container, render } from 'react-dom'; +import $ from 'jquery'; + +import features from '../../../../feature-manager'; +import isPerceptor from '../../../../helpers/is-perceptor'; +import { getRepoName, isPublicRepoWithMeta } from '../../../../helpers/get-repo-info'; +import { getOpenRank } from '../../../../api/community'; +import View from './view'; +import { CommunityOpenRankDetails } from './data'; + +const featureId = features.getFeatureID(import.meta.url); +let repoName: string; +let communityOpenRankDetails: CommunityOpenRankDetails = {}; + +const getData = async () => { + repoName = getRepoName(); + for (let year = 2020; year <= 2024; year++) { + for (let month = 1; month <= 12; month++) { + let date = year.toString() + '-' + String(month).padStart(2, '0'); + const rawData = await getOpenRank(repoName, date); + if (rawData !== null) { + communityOpenRankDetails[date] = rawData.nodes.map((node: any) => [node.n, node.c, node.v]); + } + } + } +}; + +const renderTo = (container: Container) => { + render(, container); +}; + +const init = async (): Promise => { + await getData(); + const container = document.createElement('div'); + container.id = featureId; + + $('#hypercrx-perceptor-slot-community-openrank-racing-bar').append(container); + renderTo(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, isPublicRepoWithMeta], + awaitDomReady: false, + init, + restore, +}); diff --git a/src/pages/ContentScripts/features/community-openrank-racing-bar/view.tsx b/src/pages/ContentScripts/features/community-openrank-racing-bar/view.tsx new file mode 100644 index 00000000..e6d4f5c1 --- /dev/null +++ b/src/pages/ContentScripts/features/community-openrank-racing-bar/view.tsx @@ -0,0 +1,118 @@ +import optionsStorage, { HypercrxOptions, defaults } from '../../../../options-storage'; +import RacingBar, { MediaControllers } from './RacingBar'; +import { CommunityOpenRankDetails, getMonthlyData } from './data'; +import { PlayerButton } from '../repo-activity-racing-bar/PlayerButton'; +import { SpeedController } from '../repo-activity-racing-bar/SpeedController'; + +import React, { useState, useEffect, useRef } from 'react'; +import { Space } from 'antd'; +import { SelectPicker } from 'rsuite'; +import 'rsuite/SelectPicker/styles/index.css'; +import { PlayCircleFilled, StepBackwardFilled, StepForwardFilled, PauseCircleFilled } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import '../../../../helpers/i18n'; + +interface Props { + communityOpenRankDetails: CommunityOpenRankDetails; +} + +const View = ({ communityOpenRankDetails }: Props): JSX.Element => { + const [options, setOptions] = useState(defaults); + const [speed, setSpeed] = useState(1); + const [playing, setPlaying] = useState(false); + const mediaControllersRef = useRef(null); + const { t, i18n } = useTranslation(); + const type = [ + ['All', 'a'], + ['Issue', 'i'], + ['Pull Request', 'p'], + ['User', 'u'], + ].map((item) => ({ label: item[0], value: item[1] })); + useEffect(() => { + (async function () { + setOptions(await optionsStorage.getAll()); + i18n.changeLanguage(options.locale); + })(); + }, [options.locale]); + + const onSelect = (newType: string) => { + mediaControllersRef.current?.updateType(newType); + }; + + return ( +
+
+
+ {t('component_communityOpenRankRacingBar_title')} +
+ + + {/* speed control */} + { + setSpeed(speed); + }} + /> + + {/* 3 buttons */} + + {/* last month | earliest month */} + } + onClick={mediaControllersRef.current?.previous} + onLongPress={mediaControllersRef.current?.earliest} + /> + {/* play | pause */} + : } + onClick={() => { + if (playing) { + mediaControllersRef.current?.pause(); + } else { + mediaControllersRef.current?.play(); + } + }} + /> + {/* next month | latest month */} + } + onClick={mediaControllersRef.current?.next} + onLongPress={mediaControllersRef.current?.latest} + /> + + +
+
+
+
+
+ +
+
+
+
+

{t('component_communityOpenRankRacingBar_description')}

+
+
+
+
+
+ ); +}; + +export default View; diff --git a/src/pages/ContentScripts/features/perceptor-layout/view.tsx b/src/pages/ContentScripts/features/perceptor-layout/view.tsx index cbb9f79a..d3b491b4 100644 --- a/src/pages/ContentScripts/features/perceptor-layout/view.tsx +++ b/src/pages/ContentScripts/features/perceptor-layout/view.tsx @@ -5,6 +5,8 @@ const View = (): JSX.Element => { <>
+
+
); }; diff --git a/src/pages/ContentScripts/index.ts b/src/pages/ContentScripts/index.ts index 11a5dc6f..9531d43c 100644 --- a/src/pages/ContentScripts/index.ts +++ b/src/pages/ContentScripts/index.ts @@ -13,4 +13,6 @@ import './features/repo-networks'; import './features/developer-networks'; import './features/oss-gpt'; import './features/repo-activity-racing-bar'; +import './features/community-openrank-network'; +import './features/community-openrank-racing-bar'; import './features/developer-hovercard-info'; diff --git a/yarn.lock b/yarn.lock index 20b5a462..85c4261c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1024,6 +1024,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.12.1", "@babel/runtime@^7.20.1": + version "7.24.8" + resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" + integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15", "@babel/template@^7.24.0": version "7.24.0" resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" @@ -1127,6 +1134,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@juggle/resize-observer@^3.3.1", "@juggle/resize-observer@^3.4.0": + version "3.4.0" + resolved "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.5" resolved "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" @@ -1231,6 +1243,22 @@ rc-resize-observer "^1.3.1" rc-util "^5.38.0" +"@rsuite/icon-font@^4.0.0": + version "4.0.0" + resolved "https://registry.npmmirror.com/@rsuite/icon-font/-/icon-font-4.0.0.tgz#c4a772af5020bb3bbf74761879f80da23e914123" + integrity sha512-rZTgpTH3H3HLczCA2rnkWfoMKm0ZXoRzsrkVujfP/FfslnKUMvO6w56pa8pCvhWGpNEPUsLS2ULnFGpTEcup/Q== + +"@rsuite/icons@^1.0.0", "@rsuite/icons@^1.0.2": + version "1.0.3" + resolved "https://registry.npmmirror.com/@rsuite/icons/-/icons-1.0.3.tgz#4cc9dc5732882fb56d56ff88152487846754323e" + integrity sha512-qkjYFn1v5YV9eH57Q4AJ8CwsQYfILun2wdoxhQg5+xYxkIu6UyF8vTMmpOzLvcybTE7D8STm4dH7vhpyhPOC7g== + dependencies: + "@babel/runtime" "^7.12.1" + "@rsuite/icon-font" "^4.0.0" + classnames "^2.2.5" + insert-css "^2.0.0" + lodash "^4.17.20" + "@sindresorhus/is@^4.0.0": version "4.6.0" resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" @@ -1443,6 +1471,11 @@ resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3" integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA== +"@types/lodash@^4.14.184": + version "4.17.7" + resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" + integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== + "@types/mime@^1": version "1.3.5" resolved "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" @@ -1467,7 +1500,7 @@ dependencies: undici-types "~5.26.4" -"@types/prop-types@*": +"@types/prop-types@*", "@types/prop-types@^15.7.5": version "15.7.12" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== @@ -1513,6 +1546,13 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-window@^1.8.5": + version "1.8.8" + resolved "https://registry.npmmirror.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" + integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@17.0.2", "@types/react@^17": version "17.0.2" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz#3de24c4efef902dd9795a49c75f760cbe4f7a5a8" @@ -2585,7 +2625,7 @@ data-uri-to-buffer@0.0.3: resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-0.0.3.tgz#18ae979a6a0ca994b0625853916d2662bbae0b1a" integrity sha512-Cp+jOa8QJef5nXS5hU7M1DWzXPEIoVR3kbV0dQuVGwROZg8bGf1DcCnkmajBTnvghTtSNMUdRrPjgaT6ZQucbw== -date-fns@^2.11.1: +date-fns@^2.11.1, date-fns@^2.29.3: version "2.30.0" resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== @@ -2721,6 +2761,13 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +dom-lib@^3.1.3, dom-lib@^3.3.1: + version "3.3.1" + resolved "https://registry.npmmirror.com/dom-lib/-/dom-lib-3.3.1.tgz#3a6f097d57a22b0a1c9cc08bbe5034bad5df6906" + integrity sha512-N2mpo8qQmB9wIMZJVjER+BSh4GJiZZ7S6EjnMtyETcXo90hpITUDXpUhqOcfXZ2ZefytuYYKTZMp3CGR2X+tDA== + dependencies: + "@babel/runtime" "^7.20.0" + dom-loaded@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/dom-loaded/-/dom-loaded-3.0.0.tgz#0164b9bf60ac95ea00ff0d2c8abfe68fb7769759" @@ -3225,6 +3272,13 @@ get-stream@^6.0.0, get-stream@^6.0.1: resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-value@^3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/get-value/-/get-value-3.0.1.tgz#5efd2a157f1d6a516d7524e124ac52d0a39ef5a8" + integrity sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA== + dependencies: + isobject "^3.0.1" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -3648,6 +3702,11 @@ inherits@2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== +insert-css@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/insert-css/-/insert-css-2.0.0.tgz#eb5d1097b7542f4c79ea3060d3aee07d053880f4" + integrity sha512-xGq5ISgcUP5cvGkS2MMFLtPDBtrtQPSFfC6gA6U8wHKqfjTIMZLZNxOItQnoSjdOzlXOLU/yD32RKC4SvjNbtA== + interpret@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" @@ -3740,6 +3799,11 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-primitive@^3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/is-primitive/-/is-primitive-3.0.1.tgz#98c4db1abff185485a657fc2905052b940524d05" + integrity sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -3997,7 +4061,7 @@ lodash.union@^4.6.0: resolved "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" integrity sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw== -lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4095,6 +4159,11 @@ memfs@^3.4.3: dependencies: fs-monkey "^1.0.4" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.npmmirror.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -4666,7 +4735,7 @@ process@^0.11.10: resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== -prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.7.2: +prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -5199,6 +5268,19 @@ react-redux@^7.2.4: prop-types "^15.7.2" react-is "^17.0.2" +react-use-set@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/react-use-set/-/react-use-set-1.0.0.tgz#2b8b442c6e8c77a907534dcc665d54c3f7b3c841" + integrity sha512-6BBbOcWc/tOKuwd9gDtdunvOr/g40S0SkCBYvrSJvpI0upzNlHmLoeDvylnoP8PrjQXItClAFxseVGGhEkk7kw== + +react-window@^1.8.8: + version "1.8.10" + resolved "https://registry.npmmirror.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" + integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -5421,6 +5503,40 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" +rsuite-table@^5.18.3: + version "5.18.3" + resolved "https://registry.npmmirror.com/rsuite-table/-/rsuite-table-5.18.3.tgz#08d3a6cd4cd979b1100e015a2f5dec4d37bfbfd1" + integrity sha512-Rua79XndYY+UdCUpBuH1Ew5qa54y6zLZ0RNRnudKgamksrV1j+rUhcCsA03a5ZY+b8DXTwct4V/Q6K9q/cJT5w== + dependencies: + "@babel/runtime" "^7.12.5" + "@juggle/resize-observer" "^3.3.1" + "@rsuite/icons" "^1.0.0" + classnames "^2.3.1" + dom-lib "^3.1.3" + lodash "^4.17.21" + react-is "^17.0.2" + +rsuite@^5.67.0: + version "5.67.0" + resolved "https://registry.npmmirror.com/rsuite/-/rsuite-5.67.0.tgz#a19090aff86a2c282166d858e42ac74d9ec39c01" + integrity sha512-dpuv5RzLwNRC63b1jHEac4nQkXO6sFStN9/0suq/3codGX4Glx6TtafucKjo+0iXbi4pOahbjQD/sGyWA0BO8A== + dependencies: + "@babel/runtime" "^7.20.1" + "@juggle/resize-observer" "^3.4.0" + "@rsuite/icons" "^1.0.2" + "@types/lodash" "^4.14.184" + "@types/prop-types" "^15.7.5" + "@types/react-window" "^1.8.5" + classnames "^2.3.1" + date-fns "^2.29.3" + dom-lib "^3.3.1" + lodash "^4.17.11" + prop-types "^15.8.1" + react-use-set "^1.0.0" + react-window "^1.8.8" + rsuite-table "^5.18.3" + schema-typed "^2.2.2" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -5468,6 +5584,14 @@ scheduler@^0.20.2: loose-envify "^1.1.0" object-assign "^4.1.1" +schema-typed@^2.2.2: + version "2.2.2" + resolved "https://registry.npmmirror.com/schema-typed/-/schema-typed-2.2.2.tgz#88ea6ca9f26557a4846494ab2be6ca5f23019b77" + integrity sha512-hRmqKr5V6UyhmZ0FixRVetgxvudRPjDynVZZRNq6t4EZHii7U33vmqd9uap3s4aqBcDg1JtubMNvCEmsZTpm3Q== + dependencies: + get-value "^3.0.1" + set-value "^4.1.0" + schema-utils@^2.6.5: version "2.7.1" resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" @@ -5596,6 +5720,14 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" +set-value@^4.1.0: + version "4.1.0" + resolved "https://registry.npmmirror.com/set-value/-/set-value-4.1.0.tgz#aa433662d87081b75ad88a4743bd450f044e7d09" + integrity sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw== + dependencies: + is-plain-object "^2.0.4" + is-primitive "^3.0.1" + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"