onRepoChange(v)}
+ />
+ ) : (
+
+ {t('analyze:metric_detail:pull_requests')}
+
+ )}
+ >
+ );
+ } else {
+ return (
+
+ {level === 'community' ? (
+ <>
+ onRepoChange(v)}
+ />
+
+ );
+ }
+};
+
+export default DetailHeaderFilter;
diff --git a/apps/web/src/modules/developer/components/NavBar/ChartDisplaySetting.tsx b/apps/web/src/modules/developer/components/NavBar/ChartDisplaySetting.tsx
new file mode 100644
index 000000000..e6738b123
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/ChartDisplaySetting.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { useSnapshot } from 'valtio';
+import Average from 'public/images/analyze/average.svg';
+import Median from 'public/images/analyze/median.svg';
+import { useTranslation } from 'next-i18next';
+import classnames from 'classnames';
+import { chartUserSettingState } from '@modules/developer/store';
+import Svg100 from 'public/images/analyze/number-100.svg';
+import Svg1 from 'public/images/analyze/number-1.svg';
+import YScale from 'public/images/analyze/y-scale.svg';
+
+const AvgItem = () => {
+ const { t } = useTranslation();
+ const snap = useSnapshot(chartUserSettingState);
+
+ return (
+ {
+ chartUserSettingState.showAvg = !snap.showAvg;
+ }}
+ >
+
+ {t('analyze:avg_line.show')}
+
+ );
+};
+
+const MedianItem = () => {
+ const { t } = useTranslation();
+ const snap = useSnapshot(chartUserSettingState);
+
+ return (
+ {
+ chartUserSettingState.showMedian = !snap.showMedian;
+ }}
+ >
+
+ {t('analyze:median_line.show')}
+
+ );
+};
+
+const OnePointItem = () => {
+ const { t } = useTranslation();
+ const snap = useSnapshot(chartUserSettingState);
+
+ return (
+ {
+ chartUserSettingState.onePointSys = !snap.onePointSys;
+ }}
+ >
+
+ {t('analyze:mark.percentage')}
+
+ );
+};
+
+const YScaleItem = () => {
+ const { t } = useTranslation();
+ const snap = useSnapshot(chartUserSettingState);
+
+ return (
+ {
+ chartUserSettingState.yAxisScale = !snap.yAxisScale;
+ }}
+ >
+
+ {t('analyze:y_axis_scale')}
+
+ );
+};
+
+const ChartDisplaySetting = () => {
+ const { t } = useTranslation();
+ return (
+ <>
+
+ {t('analyze:display')}
+
+
+
+
+
+ >
+ );
+};
+
+export default ChartDisplaySetting;
diff --git a/apps/web/src/modules/developer/components/NavBar/ContributorDateTagPanel.tsx b/apps/web/src/modules/developer/components/NavBar/ContributorDateTagPanel.tsx
new file mode 100644
index 000000000..da7a59590
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/ContributorDateTagPanel.tsx
@@ -0,0 +1,217 @@
+import React from 'react';
+import { BiCheck } from 'react-icons/bi';
+import { rangeTags } from '@modules/developer/constant';
+import classnames from 'classnames';
+import { useToggle } from 'react-use';
+import useI18RangeTag from './useI18RangeTag';
+import useQueryDateRange from '@modules/developer/hooks/useVerifyDateRange';
+import useSwitchRange from '@modules/developer/components/NavBar/useSwitchRange';
+import 'react-datepicker/dist/react-datepicker.css';
+import { useTranslation } from 'next-i18next';
+import DateRangePicker from './DateRangePicker';
+import Tooltip from '@common/components/Tooltip';
+import useVerifyDetailRangeQuery from '@modules/developer/hooks/useVerifyDetailRangeQuery';
+import { AiOutlineLoading } from 'react-icons/ai';
+
+const ContributorDateTagPanel = ({
+ togglePickerPanel,
+}: {
+ togglePickerPanel: (v: boolean) => void;
+}) => {
+ const { t } = useTranslation();
+ const i18RangeTag = useI18RangeTag();
+ const [showRangePicker, setShowRangePicker] = useToggle(false);
+ const { range } = useQueryDateRange();
+ const { switchRange } = useSwitchRange();
+ const { isLoading, data } = useVerifyDetailRangeQuery();
+ if (isLoading) {
+ return (
+
+ );
+ }
+ const statusFalse = !data?.verifyDetailDataRange?.status;
+ if (statusFalse) {
+ return (
+
+
+ {rangeTags.map((time, index) => {
+ if (index < 3) {
+ return (
+
{
+ await switchRange(time);
+ togglePickerPanel(false);
+ setShowRangePicker(false);
+ }}
+ >
+ {i18RangeTag[time]}
+ {range === time && !showRangePicker && (
+
+
+
+ )}
+
+ );
+ }
+ return (
+
+ {t('analyze:only_the_latest_month')}
+
+ >
+ }
+ >
+
+ {i18RangeTag[time]}
+ {range === time && !showRangePicker && (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ {t('analyze:only_the_latest_month')}
+
+ >
+ }
+ >
+
+ {t('analyze:custom')}
+
+
+
+
+ );
+ } else {
+ return (
+
+
+ {rangeTags.map((t, index) => {
+ return (
+
{
+ await switchRange(t);
+ togglePickerPanel(false);
+ setShowRangePicker(false);
+ }}
+ >
+ {i18RangeTag[t]}
+ {range === t && !showRangePicker && (
+
+
+
+ )}
+
+ );
+ })}
+
{
+ setShowRangePicker(true);
+ }}
+ >
+ {t('analyze:custom')}
+ {showRangePicker && (
+
+
+
+ )}
+
+
+
+ {
+ await switchRange(t);
+ togglePickerPanel(false);
+ }}
+ />
+
+
+ );
+ }
+};
+export default ContributorDateTagPanel;
diff --git a/apps/web/src/modules/developer/components/NavBar/DatePicker.tsx b/apps/web/src/modules/developer/components/NavBar/DatePicker.tsx
new file mode 100644
index 000000000..23af5e5b0
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/DatePicker.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import classnames from 'classnames';
+import useQueryDateRange from '@modules/developer/hooks/useQueryDateRange';
+import useSwitchRange from '@modules/developer/components/NavBar/useSwitchRange';
+import { rangeTags } from '@modules/developer/constant';
+import useI18RangeTag from './useI18RangeTag';
+
+/**
+ * @deprecated use NewDatePicker instead
+ */
+const DatePicker = () => {
+ const { range } = useQueryDateRange();
+ const { switchRange } = useSwitchRange();
+ const i18RangeTag = useI18RangeTag();
+
+ return (
+
+ {rangeTags.map((t) => {
+ return (
+
{
+ await switchRange(t);
+ }}
+ >
+ {i18RangeTag[t]}
+
+ );
+ })}
+
+ );
+};
+
+export default DatePicker;
diff --git a/apps/web/src/modules/developer/components/NavBar/DateRangePicker.tsx b/apps/web/src/modules/developer/components/NavBar/DateRangePicker.tsx
new file mode 100644
index 000000000..2a2d6fa36
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/DateRangePicker.tsx
@@ -0,0 +1,161 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ BsChevronDoubleLeft,
+ BsChevronDoubleRight,
+ BsChevronLeft,
+ BsChevronRight,
+} from 'react-icons/bs';
+import { useTranslation } from 'next-i18next';
+import DatePicker from 'react-datepicker';
+import { format, getUnixTime, fromUnixTime } from 'date-fns';
+import { registerLocale } from 'react-datepicker';
+import 'react-datepicker/dist/react-datepicker.css';
+import { enGB, zhCN } from 'date-fns/locale';
+import getLocale from '@common/utils/getLocale';
+import useQueryDateRange from '@modules/developer/hooks/useQueryDateRange';
+
+const DateRangePicker: React.FC<{
+ onClick: (t: string) => void;
+}> = ({ onClick }) => {
+ const FORMAT_YMD = 'yyyy-MM-dd';
+ const [local, setLocale] = useState('en');
+ useEffect(() => {
+ const l = getLocale();
+ setLocale(l);
+ }, []);
+ const { t } = useTranslation();
+
+ registerLocale('en', enGB);
+ registerLocale('zh', zhCN);
+ const { timeStart, timeEnd } = useQueryDateRange();
+
+ const [startDate, setStartDate] = useState(
+ timeStart || fromUnixTime(getUnixTime(new Date()) - 8 * 3600 * 24)
+ );
+ const [endDate, setEndDate] = useState(
+ timeEnd || fromUnixTime(getUnixTime(new Date()) - 1 * 3600 * 24)
+ );
+
+ return (
+
+
+
(
+
+
+
+
+
+
+ {monthDate.toLocaleString(local, {
+ year: 'numeric',
+ month: 'long',
+ })}
+
+
+
+
+
+
+ )}
+ showPopperArrow={false}
+ selected={startDate}
+ onChange={(date) => setStartDate(date)}
+ selectsStart
+ startDate={startDate}
+ endDate={endDate}
+ minDate={new Date('2000/01/01')}
+ maxDate={fromUnixTime(getUnixTime(endDate!) - 7 * 3600 * 24)}
+ className="flex h-6 w-[84px] border px-2"
+ />
+
+
~
+
+
(
+
+
+
+
+
+
+ {monthDate.toLocaleString(local, {
+ year: 'numeric',
+ month: 'long',
+ })}
+
+
+
+
+
+
+ )}
+ showPopperArrow={false}
+ selected={endDate}
+ onChange={(date) => setEndDate(date)}
+ selectsEnd
+ startDate={startDate}
+ endDate={endDate}
+ minDate={fromUnixTime(getUnixTime(startDate!) + 7 * 3600 * 24)}
+ maxDate={fromUnixTime(getUnixTime(new Date()) - 1 * 3600 * 24)}
+ className="h-6 w-[84px] border px-2"
+ />
+
+
+ onClick(
+ format(startDate!, FORMAT_YMD) +
+ ' ~ ' +
+ format(endDate!, FORMAT_YMD)
+ )
+ }
+ >
+ {t('analyze:confirm')}
+
+
+ );
+};
+export default DateRangePicker;
diff --git a/apps/web/src/modules/developer/components/NavBar/LabelItems.tsx b/apps/web/src/modules/developer/components/NavBar/LabelItems.tsx
new file mode 100644
index 000000000..656800c37
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/LabelItems.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { useTranslation } from 'next-i18next';
+import useCompareItems from '@modules/developer/hooks/useCompareItems';
+import { getProvider } from '@common/utils';
+import ImageFallback from '@common/components/ImageFallback';
+import classnames from 'classnames';
+import ProviderIcon from '../ProviderIcon';
+import client from '@common/gqlClient';
+import { useCommunityReposQuery } from '@oss-compass/graphql';
+
+const Avatar = () => {
+ return (
+
+ );
+};
+const LabelItems = () => {
+ const { t } = useTranslation();
+ const { compareItems } = useCompareItems();
+ const item = compareItems.length > 0 ? [compareItems[0]] : [];
+
+ return (
+ <>
+
+ {item.map(({ name, label, level }) => {
+ const host = getProvider(label);
+ let labelNode = (
+
{name}
+ );
+ return (
+
+ );
+ })}
+
+
+ {/* todo show compare items in mobile */}
+
+ >
+ );
+};
+
+export default LabelItems;
diff --git a/apps/web/src/modules/developer/components/NavBar/MerticDatePicker.tsx b/apps/web/src/modules/developer/components/NavBar/MerticDatePicker.tsx
new file mode 100644
index 000000000..68904cf7c
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/MerticDatePicker.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { BiCalendar, BiCaretDown } from 'react-icons/bi';
+import classnames from 'classnames';
+import useI18RangeTag from './useI18RangeTag';
+import useVerifyDateRange from '@modules/developer/hooks/useVerifyDateRange';
+import 'react-datepicker/dist/react-datepicker.css';
+import { ClickAwayListener } from '@mui/base/ClickAwayListener';
+import Popper from '@mui/material/Popper';
+import ContributorDateTagPanel from './ContributorDateTagPanel';
+
+const NavDatePicker = ({ disable }: { disable?: boolean }) => {
+ const i18RangeTag = useI18RangeTag();
+ const { range } = useVerifyDateRange();
+ const [anchorEl, setAnchorEl] = React.useState(null);
+ const [pickerPanelOpen, togglePickerPanel] = React.useState(false);
+
+ const handleClick = (event: React.MouseEvent) => {
+ if (disable) return;
+ setAnchorEl(event.currentTarget);
+ togglePickerPanel((pre) => !pre);
+ };
+
+ return (
+ {
+ if (!pickerPanelOpen) return;
+ togglePickerPanel(() => false);
+ }}
+ >
+
+
handleClick(e)}
+ >
+
+
+ {i18RangeTag[range] || range}
+
+ {disable ? null : }
+
+
+ {
+ togglePickerPanel(v);
+ }}
+ />
+
+
+
+ );
+};
+
+export default NavDatePicker;
diff --git a/apps/web/src/modules/developer/components/NavBar/NavDatePicker.tsx b/apps/web/src/modules/developer/components/NavBar/NavDatePicker.tsx
new file mode 100644
index 000000000..2d3ae57c9
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/NavDatePicker.tsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import { BiCalendar, BiCaretDown, BiCheck } from 'react-icons/bi';
+import { rangeTags } from '@modules/developer/constant';
+import classnames from 'classnames';
+import { useToggle } from 'react-use';
+import useI18RangeTag from './useI18RangeTag';
+import useQueryDateRange from '@modules/developer/hooks/useQueryDateRange';
+import useSwitchRange from '@modules/developer/components/NavBar/useSwitchRange';
+import 'react-datepicker/dist/react-datepicker.css';
+import { useTranslation } from 'next-i18next';
+import { ClickAwayListener } from '@mui/base/ClickAwayListener';
+import DateRangePicker from './DateRangePicker';
+import Popper from '@mui/material/Popper';
+import useQueryMetricType from '@modules/developer/hooks/useQueryMetricType';
+import ContributorDateTagPanel from '@modules/developer/components/NavBar/ContributorDateTagPanel';
+
+const DateTagPanel = ({
+ togglePickerPanel,
+}: {
+ togglePickerPanel: (v: boolean) => void;
+}) => {
+ const { t } = useTranslation();
+ const i18RangeTag = useI18RangeTag();
+ const [showRangePicker, setShowRangePicker] = useToggle(false);
+ const { range } = useQueryDateRange();
+ const { switchRange } = useSwitchRange();
+ const topicType = useQueryMetricType();
+
+ return (
+
+
+ {rangeTags.map((t, index) => {
+ return (
+
{
+ await switchRange(t);
+ togglePickerPanel(false);
+ setShowRangePicker(false);
+ }}
+ >
+ {i18RangeTag[t]}
+ {range === t && !showRangePicker && (
+
+
+
+ )}
+
+ );
+ })}
+
{
+ setShowRangePicker(true);
+ }}
+ >
+ {t('analyze:custom')}
+ {showRangePicker && (
+
+
+
+ )}
+
+
+
+ {
+ await switchRange(t);
+ togglePickerPanel(false);
+ }}
+ />
+
+
+ );
+};
+
+const NavDatePicker = ({ disable }: { disable?: boolean }) => {
+ const i18RangeTag = useI18RangeTag();
+ const { range } = useQueryDateRange();
+ const [anchorEl, setAnchorEl] = React.useState(null);
+ const [pickerPanelOpen, togglePickerPanel] = React.useState(false);
+ const topicType = useQueryMetricType();
+
+ const handleClick = (event: React.MouseEvent) => {
+ if (disable) return;
+ setAnchorEl(event.currentTarget);
+ togglePickerPanel((pre) => !pre);
+ };
+
+ return (
+ {
+ if (!pickerPanelOpen) return;
+ togglePickerPanel(() => false);
+ }}
+ >
+
+
handleClick(e)}
+ >
+
+ {i18RangeTag[range] || range}
+ {disable ? null : }
+
+
+ {topicType === 'contributor' ? (
+ {
+ togglePickerPanel(v);
+ }}
+ />
+ ) : (
+ {
+ togglePickerPanel(v);
+ }}
+ />
+ )}
+
+
+
+ );
+};
+
+export default NavDatePicker;
diff --git a/apps/web/src/modules/developer/components/NavBar/NavbarSetting.tsx b/apps/web/src/modules/developer/components/NavBar/NavbarSetting.tsx
new file mode 100644
index 000000000..e202109a2
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/NavbarSetting.tsx
@@ -0,0 +1,63 @@
+import React, { useRef } from 'react';
+import ChartDisplaySetting from '@modules/developer/components/NavBar/ChartDisplaySetting';
+import RepoFilter from '@modules/developer/components/NavBar/RepoFilter';
+import { AiOutlineSetting } from 'react-icons/ai';
+import Popper from '@mui/material/Popper';
+import { ClickAwayListener } from '@mui/base/ClickAwayListener';
+import useLevel from '@modules/developer/hooks/useLevel';
+import useCompareItems from '@modules/developer/hooks/useCompareItems';
+import Badge from '../Badge';
+
+const NavbarSetting: React.FC = () => {
+ const level = useLevel();
+ const { compareItems } = useCompareItems();
+ const [anchorEl, setAnchorEl] = React.useState(null);
+ const [dropdownOpen, toggleDropdown] = React.useState(false);
+
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ toggleDropdown((pre) => !pre);
+ };
+
+ return (
+ {
+ if (!open) return;
+ toggleDropdown(() => false);
+ }}
+ >
+
+
+
+
+
+ {level === 'community' && }
+ {compareItems.length === 1 ? : null}
+
+
+
+
+ );
+};
+
+export default NavbarSetting;
diff --git a/apps/web/src/modules/developer/components/NavBar/RepoFilter.tsx b/apps/web/src/modules/developer/components/NavBar/RepoFilter.tsx
new file mode 100644
index 000000000..dc833ba0e
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/RepoFilter.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { useTranslation } from 'next-i18next';
+import classnames from 'classnames';
+import { useSnapshot } from 'valtio';
+import { chartUserSettingState } from '@modules/developer/store';
+import SoftwareArtifact from 'public/images/analyze/Software-Artifact.svg';
+import Governance from 'public/images/analyze/Governance.svg';
+
+const RepoFilter = () => {
+ const { t } = useTranslation();
+ const snap = useSnapshot(chartUserSettingState);
+
+ return (
+ <>
+
+ {t('analyze:repo_filter')}
+
+ {
+ chartUserSettingState.repoType = 'software-artifact';
+ }}
+ >
+
+ {t('analyze:repos_type.software_artifact_repository')}
+
+ {
+ chartUserSettingState.repoType = 'governance';
+ }}
+ >
+
+ {t('analyze:repos_type.governance_repository')}
+
+ >
+ );
+};
+
+export default RepoFilter;
diff --git a/apps/web/src/modules/developer/components/NavBar/SubscribeButton.tsx b/apps/web/src/modules/developer/components/NavBar/SubscribeButton.tsx
new file mode 100644
index 000000000..e5e3bf808
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/SubscribeButton.tsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { BsFillBookmarkFill, BsBookmark } from 'react-icons/bs';
+import client from '@common/gqlClient';
+import {
+ useSubscriptionCountQuery,
+ useCreateSubscriptionMutation,
+ useCancelSubscriptionMutation,
+} from '@oss-compass/graphql';
+import useCompareItems from '@modules/developer/hooks/useCompareItems';
+import { CgSpinner } from 'react-icons/cg';
+import { toast } from 'react-hot-toast';
+
+const SubscribeButton = () => {
+ const { t } = useTranslation();
+ const { compareItems } = useCompareItems();
+ const [item] = compareItems;
+
+ const { data, refetch, isLoading, isFetching } = useSubscriptionCountQuery(
+ client,
+ { level: item?.level, label: item?.label },
+ { enabled: Boolean(item?.level && item?.label) }
+ );
+
+ const Create = useCreateSubscriptionMutation(client, {
+ onSuccess: () => {
+ refetch();
+ },
+ onError: (e: any) => {
+ const errors = e?.response?.errors;
+ let msg = '';
+ if (Array.isArray(errors) && errors.length > 0) {
+ msg = errors[0].message;
+ }
+
+ toast.error(msg || 'failed');
+ },
+ });
+ const Cancel = useCancelSubscriptionMutation(client, {
+ onSuccess: () => {
+ refetch();
+ },
+ });
+
+ // Show when not comparing
+ if (compareItems && compareItems.length > 1) {
+ return null;
+ }
+
+ const subscribed = data?.subjectSubscriptionCount?.subscribed;
+ const count = data?.subjectSubscriptionCount?.count;
+
+ const icon = subscribed ? (
+ <>
+
+
+ {t('analyze:subscribed')}
+
+ >
+ ) : (
+ <>
+
+
+ {t('analyze:subscribe')}
+
+ >
+ );
+
+ const loading =
+ isLoading || isFetching || Cancel.isLoading || Create.isLoading;
+
+ return (
+ {
+ if (subscribed) {
+ Cancel.mutate({ level: item?.level, label: item?.label });
+ } else {
+ Create.mutate({ level: item?.level, label: item?.label });
+ }
+ }}
+ >
+ {loading ?
: icon}
+
+ {count || 0}
+
+
+ );
+};
+
+export default SubscribeButton;
diff --git a/apps/web/src/modules/developer/components/NavBar/index.tsx b/apps/web/src/modules/developer/components/NavBar/index.tsx
new file mode 100644
index 000000000..7a0f0992b
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/index.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import classnames from 'classnames';
+import LabelItems from './LabelItems';
+import NavDatePicker from './NavDatePicker';
+import NavbarSetting from './NavbarSetting';
+
+const NavBar = () => {
+ return (
+
+ );
+};
+
+export default NavBar;
diff --git a/apps/web/src/modules/developer/components/NavBar/useI18RangeTag.tsx b/apps/web/src/modules/developer/components/NavBar/useI18RangeTag.tsx
new file mode 100644
index 000000000..edf26b8bd
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/useI18RangeTag.tsx
@@ -0,0 +1,20 @@
+import { RangeTag } from '@modules/developer/constant';
+import { useTranslation } from 'react-i18next';
+
+const useI18RangeTag = () => {
+ const { t } = useTranslation();
+ const i18RangeTag: Record = {
+ '1M': t('common:range.1M'),
+ '3M': t('common:range.3M'),
+ '6M': t('common:range.6M'),
+ '1Y': t('common:range.1Y'),
+ // '2Y': t('common:range.2Y'),
+ '3Y': t('common:range.3Y'),
+ '5Y': t('common:range.5Y'),
+ 'Since 2000': t('common:range.Since2000'),
+ };
+
+ return i18RangeTag;
+};
+
+export default useI18RangeTag;
diff --git a/apps/web/src/modules/developer/components/NavBar/useSwitchRange.ts b/apps/web/src/modules/developer/components/NavBar/useSwitchRange.ts
new file mode 100644
index 000000000..35272e4e1
--- /dev/null
+++ b/apps/web/src/modules/developer/components/NavBar/useSwitchRange.ts
@@ -0,0 +1,22 @@
+import qs from 'query-string';
+import { useRouter } from 'next/router';
+
+const useSwitchRange = () => {
+ const route = useRouter();
+
+ const switchRange = async (t: string) => {
+ const pathname = window.location.pathname;
+ const hash = window.location.hash;
+
+ const searchResult = qs.parse(window.location.search) || {};
+ searchResult.range = t;
+ const newSearch = qs.stringify(searchResult);
+
+ const url = `${pathname}?${newSearch}${hash}`;
+ await route.replace(url, undefined, { scroll: false });
+ };
+
+ return { switchRange };
+};
+
+export default useSwitchRange;
diff --git a/apps/web/src/modules/developer/components/PageInfoInit.tsx b/apps/web/src/modules/developer/components/PageInfoInit.tsx
new file mode 100644
index 000000000..f542d1ca9
--- /dev/null
+++ b/apps/web/src/modules/developer/components/PageInfoInit.tsx
@@ -0,0 +1,39 @@
+import React, { PropsWithChildren, useEffect } from 'react';
+import useCompareItems from '@modules/developer/hooks/useCompareItems';
+import { initThemeColor } from '@modules/developer/store';
+
+const getInitialTheme = (
+ compareItems: { label: string }[]
+): { label: string; paletteIndex: number }[] => {
+ return compareItems.reduce<{ label: string; paletteIndex: number }[]>(
+ (acc, cur, index) => {
+ const item = { label: cur.label, paletteIndex: index };
+ if (acc) {
+ acc.push(item);
+ return acc;
+ }
+ acc = [item];
+ return acc;
+ },
+ []
+ );
+};
+
+// init page title and label color theme
+const PageInfoInit: React.FC = ({ children }) => {
+ const { compareItems } = useCompareItems();
+
+ useEffect(() => {
+ // chart Theme
+ const initialTheme = getInitialTheme(compareItems);
+ initThemeColor(initialTheme);
+
+ // page title
+ const names = compareItems.map((i) => i.name);
+ document.title = 'OSS Compass | ' + names.join(' vs ');
+ }, [compareItems]);
+
+ return <>{children}>;
+};
+
+export default PageInfoInit;
diff --git a/apps/web/src/modules/developer/components/ProviderIcon.tsx b/apps/web/src/modules/developer/components/ProviderIcon.tsx
new file mode 100644
index 000000000..abe8f5ddb
--- /dev/null
+++ b/apps/web/src/modules/developer/components/ProviderIcon.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { SiGitee, SiGithub } from 'react-icons/si';
+import classnames from 'classnames';
+
+const ProviderIcon: React.FC<{ provider: string; className?: string }> = ({
+ provider,
+ className,
+}) => {
+ if (provider === 'gitee') {
+ return ;
+ }
+ if (provider === 'github') {
+ return ;
+ }
+ return null;
+};
+
+export default ProviderIcon;
diff --git a/apps/web/src/modules/developer/components/ScoreConversion.tsx b/apps/web/src/modules/developer/components/ScoreConversion.tsx
new file mode 100644
index 000000000..20be9b20e
--- /dev/null
+++ b/apps/web/src/modules/developer/components/ScoreConversion.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import Svg100 from 'public/images/analyze/number-100.svg';
+import Svg1 from 'public/images/analyze/number-1.svg';
+import { useTranslation } from 'next-i18next';
+import Tooltip from '@common/components/Tooltip';
+import { subscribeKey } from 'valtio/utils';
+import { chartUserSettingState } from '@modules/developer/store';
+
+const ScoreConversion: React.FC<{
+ onePoint: boolean;
+ onChange: (pre: boolean) => void;
+}> = ({ onePoint, onChange }) => {
+ const { t } = useTranslation();
+ const unsubscribe = subscribeKey(
+ chartUserSettingState,
+ 'onePointSys',
+ (v) => {
+ if (onePoint !== v) {
+ onChange(v);
+ }
+ }
+ );
+ return (
+
+ {
+ onChange(!onePoint);
+ }}
+ >
+ {onePoint ? (
+
+
+
+ {t('analyze:mark.point')}
+
+
+ ) : (
+
+
+
+ {t('analyze:mark.percentage')}
+
+
+ )}
+ {/*
*/}
+
+
+ );
+};
+
+export default ScoreConversion;
diff --git a/apps/web/src/modules/developer/components/SectionTitle.tsx b/apps/web/src/modules/developer/components/SectionTitle.tsx
new file mode 100644
index 000000000..1a49245c9
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SectionTitle.tsx
@@ -0,0 +1,30 @@
+import React, { PropsWithChildren, useRef } from 'react';
+import classnames from 'classnames';
+
+const SectionTitle: React.FC> = ({
+ children,
+ id,
+}) => {
+ return (
+
+
+ {children}
+
+
+ #
+
+
+
+ );
+};
+
+export default SectionTitle;
diff --git a/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicNicheCreation.tsx b/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicNicheCreation.tsx
new file mode 100644
index 000000000..d219b11ab
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicNicheCreation.tsx
@@ -0,0 +1,30 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import MenuTopicItem from '../Menu/MenuTopicItem';
+import MenuItem from '../Menu/MenuItem';
+import MenuSubItem from '../Menu/MenuSubItem';
+import { useOrganizationsActivity, Organizations, Topic } from '../config';
+import NicheCreationIcon from '@modules/developer/components/SideBar/assets/NicheCreation.svg';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+
+const NicheCreation = () => {
+ const { t } = useTranslation();
+ const organizationsActivity = useOrganizationsActivity();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+
+ return (
+
+
+
+ }
+ menus={''}
+ >
+ Issue
+
+ );
+};
+
+export default NicheCreation;
diff --git a/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicProductivity.tsx b/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicProductivity.tsx
new file mode 100644
index 000000000..ee8cfe6ef
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicProductivity.tsx
@@ -0,0 +1,66 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import Chaoss from '@common/components/PoweredBy/Chaoss';
+import ProductivityIcon from '@modules/developer/components/SideBar/assets/Productivity.svg';
+import MenuTopicItem from '../Menu/MenuTopicItem';
+import MenuItem from '../Menu/MenuItem';
+import MenuSubItem from '../Menu/MenuSubItem';
+import {
+ CollaborationDevelopment,
+ useCollaborationDevelopmentIndex,
+ useCommunityServiceAndSupport,
+ Support,
+ Topic,
+} from '../config';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+import { IoPeopleCircle } from 'react-icons/io5';
+
+const Productivity = () => {
+ const { t } = useTranslation();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+ const collaborationDevelopmentIndex = useCollaborationDevelopmentIndex();
+ const communityServiceAndSupport = useCommunityServiceAndSupport();
+
+ const menu = (
+ <>
+ {/*
+
+ */}
+ >
+ );
+
+ return (
+ <>
+
+
+
+ }
+ menus={menu}
+ >
+ 仓库
+
+
+
+
+ }
+ menus={menu}
+ >
+ 协作
+
+ >
+ );
+};
+
+export default Productivity;
diff --git a/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicRobustness.tsx b/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicRobustness.tsx
new file mode 100644
index 000000000..2e5ab9433
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Collaboration/TopicRobustness.tsx
@@ -0,0 +1,51 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import RobustnessIcon from '@modules/developer/components/SideBar/assets/Robustness.svg';
+import { Activity, useCommunityActivity, Topic } from '../config';
+import MenuItem from '../Menu/MenuItem';
+import MenuTopicItem from '../Menu/MenuTopicItem';
+import MenuSubItem from '../Menu/MenuSubItem';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+import Chaoss from '@common/components/PoweredBy/Chaoss';
+
+const Robustness = () => {
+ const { t } = useTranslation();
+ const communityActivity = useCommunityActivity();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+
+ const menu = (
+ <>
+ {/*
+
+
+
+ */}
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menu}
+ >
+ Code
+
+ );
+};
+
+export default Robustness;
diff --git a/apps/web/src/modules/developer/components/SideBar/Collaboration/index.tsx b/apps/web/src/modules/developer/components/SideBar/Collaboration/index.tsx
new file mode 100644
index 000000000..a88aa6471
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Collaboration/index.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import TopicProductivity from '@modules/developer/components/SideBar/Collaboration/TopicProductivity';
+import TopicRobustness from '@modules/developer/components/SideBar/Collaboration/TopicRobustness';
+import TopicNicheCreation from '@modules/developer/components/SideBar/Collaboration/TopicNicheCreation';
+
+const Divider = () => (
+
+);
+const Collaboration = () => {
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+export default Collaboration;
diff --git a/apps/web/src/modules/developer/components/SideBar/Contributor/TopicNicheCreation.tsx b/apps/web/src/modules/developer/components/SideBar/Contributor/TopicNicheCreation.tsx
new file mode 100644
index 000000000..832b6c66a
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Contributor/TopicNicheCreation.tsx
@@ -0,0 +1,34 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import MenuTopicItem from '../Menu/MenuTopicItem';
+import MenuItem from '../Menu/MenuItem';
+import { Topic } from '../config';
+import NicheCreationIcon from '@modules/developer/components/SideBar/assets/NicheCreation.svg';
+
+const NicheCreation = () => {
+ const { t } = useTranslation();
+
+ const menus = (
+ <>
+
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menus}
+ >
+ {t('analyze:topic.niche_creation')}
+
+ );
+};
+
+export default NicheCreation;
diff --git a/apps/web/src/modules/developer/components/SideBar/Contributor/TopicProductivity.tsx b/apps/web/src/modules/developer/components/SideBar/Contributor/TopicProductivity.tsx
new file mode 100644
index 000000000..e81d118e1
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Contributor/TopicProductivity.tsx
@@ -0,0 +1,107 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import ProductivityIcon from '@modules/developer/components/SideBar/assets/Productivity.svg';
+import MenuTopicItem from '../Menu/MenuTopicItem';
+import MenuItem from '../Menu/MenuItem';
+import MenuSubItem from '../Menu/MenuSubItem';
+import {
+ ContributorMilestonePersona,
+ ContributorDomainPersona,
+ ContributorRolePersona,
+ useContributorMilestonePersona,
+ useContributorDomainPersona,
+ useContributorRolePersona,
+ Topic,
+} from '../config';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+
+const Productivity = () => {
+ const { t } = useTranslation();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+ const contributorsPersonaItems = useContributorMilestonePersona();
+ const contributorRolePersonaItems = useContributorRolePersona();
+ const contributorDomainPersonaItems = useContributorDomainPersona();
+ const menu = (
+ <>
+
+
+
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menu}
+ >
+ {t('analyze:topic.productivity')}
+
+ );
+};
+
+export default Productivity;
diff --git a/apps/web/src/modules/developer/components/SideBar/Contributor/TopicRobustness.tsx b/apps/web/src/modules/developer/components/SideBar/Contributor/TopicRobustness.tsx
new file mode 100644
index 000000000..a8f07bbb1
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Contributor/TopicRobustness.tsx
@@ -0,0 +1,40 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import RobustnessIcon from '@modules/developer/components/SideBar/assets/Robustness.svg';
+import { Activity, useCommunityActivity, Topic } from '../config';
+import MenuItem from '../Menu/MenuItem';
+import MenuTopicItem from '../Menu/MenuTopicItem';
+import MenuSubItem from '../Menu/MenuSubItem';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+import Chaoss from '@common/components/PoweredBy/Chaoss';
+
+const Robustness = () => {
+ const { t } = useTranslation();
+
+ const menu = (
+ <>
+
+
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menu}
+ >
+ {t('analyze:topic.robustness')}
+
+ );
+};
+
+export default Robustness;
diff --git a/apps/web/src/modules/developer/components/SideBar/Contributor/index.tsx b/apps/web/src/modules/developer/components/SideBar/Contributor/index.tsx
new file mode 100644
index 000000000..8219fc9d9
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Contributor/index.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import TopicProductivity from '@modules/developer/components/SideBar/Contributor/TopicProductivity';
+import TopicRobustness from '@modules/developer/components/SideBar/Contributor/TopicRobustness';
+import TopicNicheCreation from '@modules/developer/components/SideBar/Contributor/TopicNicheCreation';
+
+const Divider = () => (
+
+);
+const Contributor = () => {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+export default Contributor;
diff --git a/apps/web/src/modules/developer/components/SideBar/Menu/MenuItem.tsx b/apps/web/src/modules/developer/components/SideBar/Menu/MenuItem.tsx
new file mode 100644
index 000000000..56556488c
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Menu/MenuItem.tsx
@@ -0,0 +1,84 @@
+import React, { PropsWithChildren, useRef, useState } from 'react';
+import classnames from 'classnames';
+import Popper from '@mui/material/Popper';
+import { useTranslation } from 'next-i18next';
+import Tooltip from '@common/components/Tooltip';
+
+const MenuItem: React.FC<
+ PropsWithChildren<{
+ id: string;
+ disabled?: boolean;
+ active?: boolean;
+ subMenu?: React.ReactNode;
+ leftIcons?: React.ReactNode;
+ }>
+> = ({
+ disabled = false,
+ active = false,
+ id,
+ subMenu,
+ children,
+ leftIcons,
+}) => {
+ const popoverAnchor = useRef(null);
+ const [openedPopover, setOpenedPopover] = useState(false);
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {
+ setOpenedPopover(true);
+ }}
+ onMouseLeave={() => {
+ setOpenedPopover(false);
+ }}
+ >
+ {subMenu}
+
+
+
+ );
+};
+
+export default MenuItem;
diff --git a/apps/web/src/modules/developer/components/SideBar/Menu/MenuLoading.tsx b/apps/web/src/modules/developer/components/SideBar/Menu/MenuLoading.tsx
new file mode 100644
index 000000000..c41a3b15a
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Menu/MenuLoading.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+
+const MenuLoading = () => (
+
+);
+
+export default MenuLoading;
diff --git a/apps/web/src/modules/developer/components/SideBar/Menu/MenuSubItem.tsx b/apps/web/src/modules/developer/components/SideBar/Menu/MenuSubItem.tsx
new file mode 100644
index 000000000..4eb407f6b
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Menu/MenuSubItem.tsx
@@ -0,0 +1,21 @@
+import React, { PropsWithChildren } from 'react';
+import classnames from 'classnames';
+
+const MenuSubItem: React.FC<
+ PropsWithChildren<{ id: string; active?: boolean }>
+> = ({ active = false, id, children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default MenuSubItem;
diff --git a/apps/web/src/modules/developer/components/SideBar/Menu/MenuTopicItem.tsx b/apps/web/src/modules/developer/components/SideBar/Menu/MenuTopicItem.tsx
new file mode 100644
index 000000000..e03558b13
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/Menu/MenuTopicItem.tsx
@@ -0,0 +1,33 @@
+import React, { PropsWithChildren } from 'react';
+import classnames from 'classnames';
+
+const MenuTopicItem: React.FC<
+ PropsWithChildren<{
+ hash: string;
+ active?: boolean;
+ icon: React.ReactNode;
+ menus?: React.ReactNode;
+ }>
+> = ({ active = false, hash, children, menus, icon }) => {
+ return (
+ <>
+
+ {menus}
+ >
+ );
+};
+
+export default MenuTopicItem;
diff --git a/apps/web/src/modules/developer/components/SideBar/TopicNicheCreation.tsx b/apps/web/src/modules/developer/components/SideBar/TopicNicheCreation.tsx
new file mode 100644
index 000000000..015c100bb
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/TopicNicheCreation.tsx
@@ -0,0 +1,62 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import MenuTopicItem from './Menu/MenuTopicItem';
+import MenuItem from './Menu/MenuItem';
+import MenuSubItem from './Menu/MenuSubItem';
+import { useOrganizationsActivity, Organizations, Topic } from './config';
+import NicheCreationIcon from './assets/NicheCreation.svg';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+
+const NicheCreation = () => {
+ const { t } = useTranslation();
+ const organizationsActivity = useOrganizationsActivity();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+
+ const menus = (
+ <>
+
+
+
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menus}
+ >
+ {t('analyze:topic.niche_creation')}
+
+ );
+};
+
+export default NicheCreation;
diff --git a/apps/web/src/modules/developer/components/SideBar/TopicOverview.tsx b/apps/web/src/modules/developer/components/SideBar/TopicOverview.tsx
new file mode 100644
index 000000000..03499bf56
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/TopicOverview.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useTranslation } from 'next-i18next';
+import { CiGrid41 } from 'react-icons/ci';
+import MenuTopicItem from './Menu/MenuTopicItem';
+import { Topic } from './config';
+
+const Overview = () => {
+ const { t } = useTranslation();
+ return (
+ }
+ >
+ {t('analyze:overview')}
+
+ );
+};
+
+export default Overview;
diff --git a/apps/web/src/modules/developer/components/SideBar/TopicProductivity.tsx b/apps/web/src/modules/developer/components/SideBar/TopicProductivity.tsx
new file mode 100644
index 000000000..c4cfd71b7
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/TopicProductivity.tsx
@@ -0,0 +1,96 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import Chaoss from '@common/components/PoweredBy/Chaoss';
+import ProductivityIcon from './assets/Productivity.svg';
+import MenuTopicItem from './Menu/MenuTopicItem';
+import MenuItem from './Menu/MenuItem';
+import MenuSubItem from './Menu/MenuSubItem';
+import {
+ CollaborationDevelopment,
+ useCollaborationDevelopmentIndex,
+ useCommunityServiceAndSupport,
+ Support,
+ Topic,
+} from './config';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+
+const Productivity = () => {
+ const { t } = useTranslation();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+ const collaborationDevelopmentIndex = useCollaborationDevelopmentIndex();
+ const communityServiceAndSupport = useCommunityServiceAndSupport();
+
+ const menu = (
+ <>
+ }
+ subMenu={
+ <>
+ {collaborationDevelopmentIndex.groups.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })}
+ >
+ }
+ >
+ {t('metrics_models:collaboration_development_index.title')}
+
+ }
+ subMenu={
+ <>
+ {communityServiceAndSupport.groups.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })}
+ >
+ }
+ >
+ {t('metrics_models:community_service_and_support.title')}
+
+
+
+
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menu}
+ >
+ {t('analyze:topic.productivity')}
+
+ );
+};
+
+export default Productivity;
diff --git a/apps/web/src/modules/developer/components/SideBar/TopicRobustness.tsx b/apps/web/src/modules/developer/components/SideBar/TopicRobustness.tsx
new file mode 100644
index 000000000..9d2725f8e
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/TopicRobustness.tsx
@@ -0,0 +1,73 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'next-i18next';
+import RobustnessIcon from './assets/Robustness.svg';
+import { Activity, useCommunityActivity, Topic } from './config';
+import MenuItem from './Menu/MenuItem';
+import MenuTopicItem from './Menu/MenuTopicItem';
+import MenuSubItem from './Menu/MenuSubItem';
+import { SideBarContext } from '@modules/developer/context/SideBarContext';
+import Chaoss from '@common/components/PoweredBy/Chaoss';
+
+const Robustness = () => {
+ const { t } = useTranslation();
+ const communityActivity = useCommunityActivity();
+ const { menuId, subMenuId } = useContext(SideBarContext);
+
+ const menu = (
+ <>
+ }
+ subMenu={
+ <>
+ {communityActivity.groups.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })}
+ >
+ }
+ >
+ {t('metrics_models:activity.title')}
+
+
+
+
+
+
+ >
+ );
+
+ return (
+
+
+
+ }
+ menus={menu}
+ >
+ {t('analyze:topic.robustness')}
+
+ );
+};
+
+export default Robustness;
diff --git a/apps/web/src/modules/developer/components/SideBar/TopicTab.tsx b/apps/web/src/modules/developer/components/SideBar/TopicTab.tsx
new file mode 100644
index 000000000..7ceadea03
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/TopicTab.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { useTranslation } from 'next-i18next';
+import MyTab from '@common/components/Tab';
+import useQueryMetricType from '@modules/developer/hooks/useQueryMetricType';
+import useSwitchMetricType from '@modules/developer/hooks/useSwitchMetricType';
+
+const TopicTab = () => {
+ const { t } = useTranslation();
+ const topicType = useQueryMetricType();
+ const { switchMetricType } = useSwitchMetricType();
+
+ const tabOptions = [
+ {
+ label: t('analyze:collaboration'),
+ value: 'collaboration',
+ tabCls: 'min-w-[66px] text-center',
+ },
+ {
+ label: t('analyze:contributor'),
+ value: 'contributor',
+ tabCls: 'min-w-[80px] text-center',
+ },
+ {
+ label: t('analyze:software'),
+ value: 'software',
+ disable: true,
+ tooltip: t('analyze:coming_soon'),
+ tabCls: 'min-w-[60px] text-center',
+ },
+ ];
+
+ return (
+
+
+ switchMetricType(v)}
+ />
+
+
+ );
+};
+
+export default TopicTab;
diff --git a/apps/web/src/modules/developer/components/SideBar/assets/NicheCreation.svg b/apps/web/src/modules/developer/components/SideBar/assets/NicheCreation.svg
new file mode 100644
index 000000000..dd245fbc4
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/assets/NicheCreation.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/apps/web/src/modules/developer/components/SideBar/assets/Productivity.svg b/apps/web/src/modules/developer/components/SideBar/assets/Productivity.svg
new file mode 100644
index 000000000..6dc6ada3b
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/assets/Productivity.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/apps/web/src/modules/developer/components/SideBar/assets/Robustness.svg b/apps/web/src/modules/developer/components/SideBar/assets/Robustness.svg
new file mode 100644
index 000000000..4cc78a749
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/assets/Robustness.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/apps/web/src/modules/developer/components/SideBar/config.ts b/apps/web/src/modules/developer/components/SideBar/config.ts
new file mode 100644
index 000000000..4525c016d
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/config.ts
@@ -0,0 +1,419 @@
+import { useTranslation } from 'next-i18next';
+
+export enum CollaborationDevelopment {
+ Overview = 'collaboration_development_index_overview',
+ ContributorCount = 'code_quality_contributor_count',
+ CommitFrequency = 'code_quality_commit_frequency',
+ IsMaintained = 'code_quality_is_maintained',
+ CommitPRLinkedRatio = 'code_quality_commit_pr_linked_ratio',
+ PRIssueLinkedRatio = 'code_quality_pr_issue_linked_ratio',
+ CodeReviewRatio = 'code_quality_code_review_ratio',
+ CodeMergeRatio = 'code_quality_code_merge_ratio',
+ LocFrequency = 'code_quality_loc_frequency',
+}
+
+export enum Support {
+ Overview = 'support_overview',
+ IssueFirstResponse = 'support_issue_first_response',
+ BugIssueOpenTime = 'support_issue_open_time',
+ CommentFrequency = 'support_comment_frequency',
+ UpdatedIssuesCount = 'support_updated_issues_count',
+ PrOpenTime = 'support_pr_open_time',
+ CodeReviewCount = 'support_code_review_count',
+ ClosedPrsCount = 'support_closed_prs_count',
+}
+
+export enum Activity {
+ Overview = 'activity_overview',
+ ContributorCount = 'activity_contributor_count',
+ CommitFrequency = 'activity_commit_frequency',
+ UpdatedSince = 'activity_updated_since',
+ OrgCount = 'activity_org_count',
+ CreatedSince = 'activity_created_since',
+ CommentFrequency = 'activity_comment_frequency',
+ CodeReviewCount = 'activity_code_review_count',
+ UpdatedIssuesCount = 'activity_updated_issues_count',
+ RecentReleasesCount = 'activity_recent_releases_count',
+}
+
+export enum Organizations {
+ Overview = 'organizations_activity_overview',
+ ContributorCount = 'organizations_activity_contributor_count',
+ CommitFrequency = 'organizations_activity_commit_frequency',
+ OrgCount = 'organizations_activity_org_count',
+ ContributionLast = 'organizations_activity_contribution_last',
+ // MaintainerCount = "MaintainerCount",
+ // MeetingFrequency= 'MeetingFrequency',
+ // MeetingAttendeeCount = "MeetingAttendeeCount"
+}
+export enum ContributorMilestonePersona {
+ Overview = 'milestone_persona_overview',
+ ActivityCasualCount = 'activity_casual_contributor_count',
+ ActivityCasualContribution = 'activity_casual_contribution_per_person',
+ ActivityRegularCount = 'activity_regular_contributor_count',
+ ActivityRegularContribution = 'activity_regular_contribution_per_person',
+ ActivityCoreCount = 'activity_core_contributor_count',
+ ActivityCoreContribution = 'activity_core_contribution_per_person',
+}
+export enum ContributorRolePersona {
+ Overview = 'role_persona_overview',
+ ActivityOrganizationCount = 'activity_organization_contributor_count',
+ ActivityOrganizationContribution = 'activity_organization_contribution_per_person',
+ ActivityIndividualCount = 'activity_individual_contributor_count',
+ ActivityIndividualContribution = 'activity_individual_contribution_per_person',
+}
+export enum ContributorDomainPersona {
+ Overview = 'domain_persona_overview',
+ ActivityObservationCount = 'activity_observation_contributor_count',
+ ActivityObservationContribution = 'activity_observation_contribution_per_person',
+ ActivityCodeCount = 'activity_code_contributor_count',
+ ActivityCodeContribution = 'activity_code_contribution_per_person',
+ ActivityIssueCount = 'activity_issue_contributor_count',
+ ActivityIssueContribution = 'activity_issue_contribution_per_person',
+}
+
+export enum Topic {
+ Overview = 'topic_overview',
+ Productivity = 'topic_productivity',
+ Robustness = 'topic_robustness',
+ NicheCreation = 'topic_niche_creation',
+}
+
+export enum Section {
+ CollaborationDevelopmentIndex = 'collaboration_development_index',
+ CommunityServiceAndSupport = 'community_service_support',
+ CommunityActivity = 'community_activity',
+ OrganizationsActivity = 'organizations_activity',
+ ContributorMilestonePersona = 'contributor_milestone_persona',
+ ContributorDomainPersona = 'contributor_domain_persona',
+ ContributorRolePersona = 'contributor_role_persona',
+}
+
+export const useCollaborationDevelopmentIndex = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.Productivity,
+ name: t('metrics_models:collaboration_development_index.title'),
+ id: CollaborationDevelopment.Overview,
+ groups: [
+ // { name: 'Overview', id: CodeQuality.Overview },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.contributor_count'
+ ),
+ id: CollaborationDevelopment.ContributorCount,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.commit_frequency'
+ ),
+ id: CollaborationDevelopment.CommitFrequency,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.is_maintained'
+ ),
+ id: CollaborationDevelopment.IsMaintained,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.commit_pr_linked_ratio'
+ ),
+ id: CollaborationDevelopment.CommitPRLinkedRatio,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.pr_issue_linked_ratio'
+ ),
+ id: CollaborationDevelopment.PRIssueLinkedRatio,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.code_review_ratio'
+ ),
+ id: CollaborationDevelopment.CodeReviewRatio,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.code_merge_ratio'
+ ),
+ id: CollaborationDevelopment.CodeMergeRatio,
+ },
+ {
+ name: t(
+ 'metrics_models:collaboration_development_index.metrics.lines_of_code_frequency'
+ ),
+ id: CollaborationDevelopment.LocFrequency,
+ },
+ ],
+ };
+};
+
+export const useCommunityServiceAndSupport = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.Productivity,
+ name: t('metrics_models:community_service_and_support.title'),
+ id: Support.Overview,
+ groups: [
+ // { name: 'Overview', id: Support.Overview },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.updated_issues_count'
+ ),
+ id: Support.UpdatedIssuesCount,
+ },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.close_pr_count'
+ ),
+ id: Support.ClosedPrsCount,
+ },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.issue_first_response'
+ ),
+ id: Support.IssueFirstResponse,
+ },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.bug_issue_open_time'
+ ),
+ id: Support.BugIssueOpenTime,
+ },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.pr_open_time'
+ ),
+ id: Support.PrOpenTime,
+ },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.comment_frequency'
+ ),
+ id: Support.CommentFrequency,
+ },
+ {
+ name: t(
+ 'metrics_models:community_service_and_support.metrics.code_review_count'
+ ),
+ id: Support.CodeReviewCount,
+ },
+ ],
+ };
+};
+
+export const useCommunityActivity = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.Robustness,
+ name: t('metrics_models:community_activity.title'),
+ id: Activity.Overview,
+ groups: [
+ // { name: 'Overview', id: Activity.Overview },
+ {
+ name: t('metrics_models:community_activity.metrics.contributor_count'),
+ id: Activity.ContributorCount,
+ },
+ {
+ name: t('metrics_models:community_activity.metrics.commit_frequency'),
+ id: Activity.CommitFrequency,
+ },
+ {
+ name: t('metrics_models:community_activity.metrics.updated_since'),
+ id: Activity.UpdatedSince,
+ },
+ {
+ name: t('metrics_models:community_activity.metrics.organization_count'),
+ id: Activity.OrgCount,
+ },
+ // {
+ // name: t('metrics_models:community_activity.metrics.created_since'),
+ // id: Activity.CreatedSince,
+ // },
+ {
+ name: t('metrics_models:community_activity.metrics.comment_frequency'),
+ id: Activity.CommentFrequency,
+ },
+ {
+ name: t('metrics_models:community_activity.metrics.code_review_count'),
+ id: Activity.CodeReviewCount,
+ },
+ {
+ name: t(
+ 'metrics_models:community_activity.metrics.updated_issues_count'
+ ),
+ id: Activity.UpdatedIssuesCount,
+ },
+ {
+ name: t(
+ 'metrics_models:community_activity.metrics.recent_releases_count'
+ ),
+ id: Activity.RecentReleasesCount,
+ },
+ ],
+ };
+};
+
+export const useOrganizationsActivity = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.NicheCreation,
+ name: t('metrics_models:organization_activity.title'),
+ id: Organizations.Overview,
+ groups: [
+ {
+ name: t(
+ 'metrics_models:organization_activity.metrics.contributor_count'
+ ),
+ id: Organizations.ContributorCount,
+ },
+ {
+ name: t(
+ 'metrics_models:organization_activity.metrics.commit_frequency'
+ ),
+ id: Organizations.CommitFrequency,
+ },
+ {
+ name: t('metrics_models:organization_activity.metrics.org_count'),
+ id: Organizations.OrgCount,
+ },
+ {
+ name: t(
+ 'metrics_models:organization_activity.metrics.contribution_last'
+ ),
+ id: Organizations.ContributionLast,
+ },
+ // { name: 'Maintainer Count', id: Organizations.MaintainerCount },
+ // { name: 'MeetingFrequency', id: Organizations.MeetingFrequency },
+ // { name: 'Meeting Attendee Count', id: Organizations.MeetingAttendeeCount },
+ ],
+ };
+};
+export const useContributorMilestonePersona = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.Productivity,
+ name: t('metrics_models:contributor_milestone_persona.title'),
+ id: ContributorMilestonePersona.Overview,
+ groups: [
+ {
+ name: t(
+ 'metrics_models:contributor_milestone_persona.metrics.activity_core_contributor_count'
+ ),
+ id: ContributorMilestonePersona.ActivityCoreCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_milestone_persona.metrics.activity_core_contribution_per_person'
+ ),
+ id: ContributorMilestonePersona.ActivityCoreContribution,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_milestone_persona.metrics.activity_regular_contributor_count'
+ ),
+ id: ContributorMilestonePersona.ActivityRegularCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_milestone_persona.metrics.activity_regular_contribution_per_person'
+ ),
+ id: ContributorMilestonePersona.ActivityRegularContribution,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_milestone_persona.metrics.activity_casual_contributor_count'
+ ),
+ id: ContributorMilestonePersona.ActivityCasualCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_milestone_persona.metrics.activity_casual_contribution_per_person'
+ ),
+ id: ContributorMilestonePersona.ActivityCasualContribution,
+ },
+ ],
+ };
+};
+export const useContributorRolePersona = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.Productivity,
+ name: t('metrics_models:contributor_role_persona.title'),
+ id: ContributorRolePersona.Overview,
+ groups: [
+ {
+ name: t(
+ 'metrics_models:contributor_role_persona.metrics.activity_organization_contributor_count'
+ ),
+ id: ContributorRolePersona.ActivityOrganizationCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_role_persona.metrics.activity_organization_contribution_per_person'
+ ),
+ id: ContributorRolePersona.ActivityOrganizationContribution,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_role_persona.metrics.activity_individual_contributor_count'
+ ),
+ id: ContributorRolePersona.ActivityIndividualCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_role_persona.metrics.activity_individual_contribution_per_person'
+ ),
+ id: ContributorRolePersona.ActivityIndividualContribution,
+ },
+ ],
+ };
+};
+
+export const useContributorDomainPersona = () => {
+ const { t } = useTranslation();
+ return {
+ topic: Topic.Productivity,
+ name: t('metrics_models:contributor_domain_persona.title'),
+ id: ContributorDomainPersona.Overview,
+ groups: [
+ {
+ name: t(
+ 'metrics_models:contributor_domain_persona.metrics.activity_code_contributor_count'
+ ),
+ id: ContributorDomainPersona.ActivityCodeCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_domain_persona.metrics.activity_code_contribution_per_person'
+ ),
+ id: ContributorDomainPersona.ActivityCodeContribution,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_domain_persona.metrics.activity_issue_contributor_count'
+ ),
+ id: ContributorDomainPersona.ActivityIssueCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_domain_persona.metrics.activity_issue_contribution_per_person'
+ ),
+ id: ContributorDomainPersona.ActivityIssueContribution,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_domain_persona.metrics.activity_observation_contributor_count'
+ ),
+ id: ContributorDomainPersona.ActivityObservationCount,
+ },
+ {
+ name: t(
+ 'metrics_models:contributor_domain_persona.metrics.activity_observation_contribution_per_person'
+ ),
+ id: ContributorDomainPersona.ActivityObservationContribution,
+ },
+ ],
+ };
+};
diff --git a/apps/web/src/modules/developer/components/SideBar/index.tsx b/apps/web/src/modules/developer/components/SideBar/index.tsx
new file mode 100644
index 000000000..7225c3eba
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/index.tsx
@@ -0,0 +1,81 @@
+import React, { PropsWithChildren } from 'react';
+import classnames from 'classnames';
+import { withErrorBoundary } from 'react-error-boundary';
+import { usePrevious, useWindowScroll } from 'react-use';
+import { useStatusContext } from '@modules/developer/context';
+import { checkIsPending } from '@modules/developer/constant';
+import useHashchangeEvent from '@common/hooks/useHashchangeEvent';
+import MenuLoading from '@modules/developer/components/SideBar/Menu/MenuLoading';
+import TopicOverview from '@modules/developer/components/SideBar/TopicOverview';
+import Collaboration from '@modules/developer/components/SideBar/Collaboration';
+import useActiveMenuId from '@modules/developer/components/SideBar/useActiveMenuId';
+import NoSsr from '@common/components/NoSsr';
+import { SideBarContextProvider } from '@modules/developer/context/SideBarContext';
+import ErrorFallback from '@common/components/ErrorFallback';
+
+const SideBarMenuContent = () => {
+ const activeId = useHashchangeEvent();
+ const active = useActiveMenuId(activeId);
+ let source = ;
+ return (
+
+
+ {source}
+
+ );
+};
+
+export const SideBarMenu: React.FC = ({ children }) => {
+ const { status } = useStatusContext();
+
+ if (checkIsPending(status)) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+};
+
+const SideBarWrap: React.FC = ({ children }) => {
+ const { y } = useWindowScroll();
+ const preY = usePrevious(y) as number;
+
+ return (
+
+ );
+};
+
+const SideBar = () => {
+ return (
+
+
+
+ );
+};
+
+export default withErrorBoundary(SideBar, {
+ FallbackComponent: ErrorFallback,
+ onError(error, info) {
+ console.log(error, info);
+ // Do something with the error
+ // E.g. log to an error logging client here
+ },
+});
diff --git a/apps/web/src/modules/developer/components/SideBar/useActiveMenuId.ts b/apps/web/src/modules/developer/components/SideBar/useActiveMenuId.ts
new file mode 100644
index 000000000..760c6adb9
--- /dev/null
+++ b/apps/web/src/modules/developer/components/SideBar/useActiveMenuId.ts
@@ -0,0 +1,56 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useDebounce } from 'react-use';
+import {
+ useCollaborationDevelopmentIndex,
+ useCommunityActivity,
+ useCommunityServiceAndSupport,
+ useOrganizationsActivity,
+} from './config';
+
+const useActiveMenuId = (activeId: string) => {
+ const collaborationDevelopmentIndex = useCollaborationDevelopmentIndex();
+ const communityActivity = useCommunityActivity();
+ const communityServiceAndSupport = useCommunityServiceAndSupport();
+ const organizationsActivity = useOrganizationsActivity();
+
+ return useMemo(() => {
+ return [
+ collaborationDevelopmentIndex,
+ communityServiceAndSupport,
+ communityActivity,
+ organizationsActivity,
+ ].reduce<{ topicId: string; menuId: string; subMenuId: string }>(
+ (acc, cur) => {
+ const { topic, id, groups } = cur;
+
+ const item = (groups as { name: string; id: string }[]).find(
+ (item) => item.id === activeId
+ );
+ // submenu
+ if (item) {
+ return { topicId: topic, menuId: cur.id, subMenuId: item.id || '' };
+ }
+
+ if (id === activeId) {
+ return { topicId: topic, menuId: cur.id, subMenuId: '' };
+ }
+
+ // topic
+ if (topic === activeId) {
+ return { topicId: topic, menuId: '', subMenuId: '' };
+ }
+
+ return acc;
+ },
+ { topicId: '', menuId: '', subMenuId: '' }
+ );
+ }, [
+ activeId,
+ collaborationDevelopmentIndex,
+ communityActivity,
+ communityServiceAndSupport,
+ organizationsActivity,
+ ]);
+};
+
+export default useActiveMenuId;
diff --git a/apps/web/src/modules/developer/components/TableList.tsx b/apps/web/src/modules/developer/components/TableList.tsx
new file mode 100644
index 000000000..962b25664
--- /dev/null
+++ b/apps/web/src/modules/developer/components/TableList.tsx
@@ -0,0 +1,277 @@
+import React, { useState } from 'react';
+import Link from 'next/link';
+import type { PropsWithChildren, ComponentProps } from 'react';
+import classnames from 'classnames';
+import { BsCodeSquare } from 'react-icons/bs';
+import BaseCard from '@common/components/BaseCard';
+import { useMetricModelsOverviewQuery } from '@oss-compass/graphql';
+import client from '@common/gqlClient';
+import useCompareItems from '@modules/developer/hooks/useCompareItems';
+import { useQueries } from '@tanstack/react-query';
+import { formatISO, getShortAnalyzeLink, toFixed } from '@common/utils';
+import transHundredMarkSystem from '@common/transform/transHundredMarkSystem';
+import { Topic } from '@modules/developer/components/SideBar/config';
+import { formatRepoName } from '@common/utils/format';
+import ScoreConversion from '@modules/developer/components/ScoreConversion';
+import { useTranslation } from 'next-i18next';
+import { Level } from '@modules/developer/constant';
+import { chartUserSettingState } from '@modules/developer/store';
+import { useSnapshot } from 'valtio';
+import useQueryMetricType from '@modules/developer/hooks/useQueryMetricType';
+
+const getModelScore = (list, models) => {
+ return list.map((item) => {
+ const obj = {};
+ if (item.length > 0) {
+ obj['label'] = item[0].label;
+ obj['level'] = item[0].level;
+ obj['shortCode'] = item[0].shortCode;
+ obj['activityScoreUpdatedAt'] = item[0].grimoireCreationDate;
+ }
+ const tableData = models.map((z) => {
+ const s = item.find((y) => y.ident === z.key);
+ return { mainScore: s?.mainScore, key: z.key };
+ });
+ obj['tableData'] = tableData;
+ return obj;
+ });
+};
+
+const borderList = [
+ 'border-t-[#90E6FF]',
+ 'border-t-[#FFB290]',
+ 'border-t-[#B990FF]',
+ 'border-t-[#61a2ff]',
+];
+const bgList = ['bg-[#f2fcff]', 'bg-[#fff9f3]', 'bg-[#f8f3ff]', 'bg-[#ddebff]'];
+const TT: React.FC>> = ({
+ children,
+ className,
+ ...props
+}) => {
+ return (
+
+ {children}
+ |
+ );
+};
+
+const Th: React.FC>> = ({
+ children,
+ className,
+ ...props
+}) => {
+ return (
+
+ {children}
+ |
+ );
+};
+
+const Td: React.FC>> = ({
+ children,
+ className,
+ ...props
+}) => {
+ return (
+
+ {children}
+ |
+ );
+};
+
+const TrendsList: React.FC = () => {
+ const { t } = useTranslation();
+ const models = [
+ {
+ name: t('analyze:all_model:collaboration_development_index'),
+ key: 'collab_dev_index',
+ value: null,
+ scope: 'collaboration',
+ },
+ {
+ name: t('analyze:all_model:community_service_and_support'),
+ key: 'community',
+ value: null,
+ scope: 'collaboration',
+ },
+ {
+ name: t('analyze:all_model:community_activity'),
+ key: 'activity',
+ value: null,
+ scope: 'collaboration',
+ },
+ {
+ name: t('analyze:all_model:organization_activity'),
+ key: 'organizations_activity',
+ value: null,
+ scope: 'collaboration',
+ },
+ {
+ name: t('analyze:all_model:contributors_milestone_persona'),
+ key: 'milestone_persona',
+ value: null,
+ scope: 'contributor',
+ },
+
+ {
+ name: t('analyze:all_model:contributors_role_persona'),
+ key: 'role_persona',
+ value: null,
+ scope: 'contributor',
+ },
+ {
+ name: t('analyze:all_model:contributors_domain_persona'),
+ key: 'domain_persona',
+ value: null,
+ scope: 'contributor',
+ },
+ ];
+ const [onePointSys, setOnePointSys] = useState(false);
+
+ const { compareItems } = useCompareItems();
+ const snap = useSnapshot(chartUserSettingState);
+ const rType = snap.repoType;
+ const topicType = useQueryMetricType();
+ const tHeader = models.filter((item) => item.scope === topicType);
+ const data = useQueries({
+ queries: compareItems.map(({ label, level }) => {
+ const repoType = level === Level.COMMUNITY ? rType : null;
+ return {
+ queryKey: useMetricModelsOverviewQuery.getKey({
+ label,
+ level,
+ repoType,
+ }),
+ queryFn: useMetricModelsOverviewQuery.fetcher(client, {
+ label,
+ level,
+ repoType,
+ }),
+ };
+ }),
+ });
+ const loading = data.some((i) => i.isLoading);
+ const allList = data.map((i) => i.data?.metricModelsOverview).filter(Boolean);
+ const list = getModelScore(allList, tHeader);
+ const labels = list.map((item) => item?.label).filter(Boolean) as string[];
+ const formatScore = (num: number | null | undefined) => {
+ if (num === undefined || num === null) return '-';
+ return onePointSys ? toFixed(num, 3) : transHundredMarkSystem(num);
+ };
+
+ return (
+ {
+ setOnePointSys(v);
+ }}
+ />
+ }
+ >
+
+
+
+
+ |
+ {tHeader.map((item, index) => {
+ return (
+
+ {item.name}
+
+ );
+ })}
+
+
+
+ {Array.isArray(list) &&
+ list.map((item, index) => {
+ const r = formatRepoName({
+ label: item!.label!,
+ compareLabels: labels,
+ });
+
+ return (
+
+ |
+
+
+ {r.name}
+ {item?.level === Level.COMMUNITY ? (
+
+ {t('home:community')}
+
+ ) : null}
+
+
+ {r.meta?.namespace}
+ {r.meta?.showProvider
+ ? ` on ${r.meta?.provider}`
+ : ''}
+
+ {item?.level === Level.COMMUNITY ? (
+
+ {/*
+
+ {item?.reposCount}
+ {t('analyze:repos')}
+ */}
+
+ ) : null}
+
+ {`${t('analyze:updated_on')} ${formatISO(
+ item!.activityScoreUpdatedAt!
+ )}`}
+
+
+ |
+ {tHeader.map((z, i) => {
+ return (
+
+ {formatScore(item.tableData[i].mainScore)}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+export default TrendsList;
diff --git a/apps/web/src/modules/developer/components/TopicNavbar.tsx b/apps/web/src/modules/developer/components/TopicNavbar.tsx
new file mode 100644
index 000000000..1c692c6bd
--- /dev/null
+++ b/apps/web/src/modules/developer/components/TopicNavbar.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import classnames from 'classnames';
+import useTopicNavbarScroll from '@modules/developer/hooks/useTopicNavbarScroll';
+import { CiGrid41 } from 'react-icons/ci';
+import ProductivityIcon from '@modules/developer/components/SideBar/assets/Productivity.svg';
+import RobustnessIcon from '@modules/developer/components/SideBar/assets/Robustness.svg';
+import NicheCreationIcon from '@modules/developer/components/SideBar/assets/NicheCreation.svg';
+
+interface IconMap {
+ [key: string]: React.ReactNode;
+}
+
+const iconMap: IconMap = {
+ Overview: ,
+ Productivity: ,
+ Robustness: ,
+ 'Niche Creation': ,
+ 概览: ,
+ 生产力: ,
+ 稳健性: ,
+ 创新力: ,
+};
+
+const TopicNavbar = () => {
+ const { topicTitle, subTitle } = useTopicNavbarScroll();
+
+ if (!topicTitle) return null;
+
+ return (
+
+ );
+};
+
+export default TopicNavbar;
diff --git a/apps/web/src/modules/developer/components/TopicTitle.tsx b/apps/web/src/modules/developer/components/TopicTitle.tsx
new file mode 100644
index 000000000..18cd05078
--- /dev/null
+++ b/apps/web/src/modules/developer/components/TopicTitle.tsx
@@ -0,0 +1,26 @@
+import React, { PropsWithChildren, useRef } from 'react';
+import classnames from 'classnames';
+
+const TopicTitle: React.FC<
+ PropsWithChildren<{ id: string; paddingTop?: boolean; icon: React.ReactNode }>
+> = ({ children, id, paddingTop = false, icon }) => {
+ return (
+
+ {icon}
+ {children}
+
+
+ #
+
+
+
+ );
+};
+
+export default TopicTitle;
diff --git a/apps/web/src/modules/developer/components/urlTool.ts b/apps/web/src/modules/developer/components/urlTool.ts
new file mode 100644
index 000000000..aa3988e8f
--- /dev/null
+++ b/apps/web/src/modules/developer/components/urlTool.ts
@@ -0,0 +1,13 @@
+const qs = require('query-string');
+
+export const removeSearchValue = (value: string): string => {
+ const { pathname, search } = window.location;
+ const result = qs.parse(search);
+
+ if (Array.isArray(result['label'])) {
+ result.label = result['label'].filter((i: string) => !i.includes(value));
+ const newSearch = qs.stringify(result);
+ return `${pathname}?${newSearch}`;
+ }
+ return '';
+};
diff --git a/apps/web/src/modules/developer/constant.ts b/apps/web/src/modules/developer/constant.ts
new file mode 100644
index 000000000..cfbddd732
--- /dev/null
+++ b/apps/web/src/modules/developer/constant.ts
@@ -0,0 +1,52 @@
+import { subMonths, subYears } from 'date-fns';
+
+export const timeRange = {
+ '1M': {
+ start: subMonths(new Date(), 1),
+ end: new Date(),
+ },
+ '3M': {
+ start: subMonths(new Date(), 3),
+ end: new Date(),
+ },
+ '6M': {
+ start: subMonths(new Date(), 6),
+ end: new Date(),
+ },
+ '1Y': {
+ start: subYears(new Date(), 1),
+ end: new Date(),
+ },
+ // '2Y': {
+ // start: subYears(new Date(), 2),
+ // end: new Date(),
+ // },
+ '3Y': {
+ start: subYears(new Date(), 3),
+ end: new Date(),
+ },
+ '5Y': {
+ start: subYears(new Date(), 5),
+ end: new Date(),
+ },
+ 'Since 2000': {
+ start: new Date('2000'),
+ end: new Date(),
+ },
+};
+
+export type RangeTag = keyof typeof timeRange;
+
+const getTimeRangeTags = () => Object.keys(timeRange) as unknown as RangeTag[];
+
+export const rangeTags = getTimeRangeTags();
+
+export enum Level {
+ COMMUNITY = 'community',
+ PROJECT = 'project',
+ REPO = 'repo',
+}
+
+// todo: add pages
+// pending progress success error canceled unsumbit
+export const checkIsPending = (status: string) => !['success'].includes(status);
diff --git a/apps/web/src/modules/developer/context/ChartsDataProvider.tsx b/apps/web/src/modules/developer/context/ChartsDataProvider.tsx
new file mode 100644
index 000000000..b79a92014
--- /dev/null
+++ b/apps/web/src/modules/developer/context/ChartsDataProvider.tsx
@@ -0,0 +1,203 @@
+import React, {
+ PropsWithChildren,
+ useEffect,
+ useRef,
+ createContext,
+} from 'react';
+import { proxy, useSnapshot } from 'valtio';
+import useQueryDateRange from '@modules/developer/hooks/useQueryDateRange';
+import useCompareItems from '@modules/developer/hooks/useCompareItems';
+import usePageLoadHashScroll from '@common/hooks/usePageLoadHashScroll';
+import { QueryStatus, useQueries, useQueryClient } from '@tanstack/react-query';
+import {
+ MetricQuery,
+ MetricContributorQuery,
+ SummaryQuery,
+ useMetricQuery,
+ useMetricContributorQuery,
+ useSummaryQuery,
+} from '@oss-compass/graphql';
+import client from '@common/gqlClient';
+import { Level } from '@modules/developer/constant';
+import { chartUserSettingState } from '@modules/developer/store';
+import useQueryMetricType from '@modules/developer/hooks/useQueryMetricType';
+
+interface Store {
+ loading: boolean;
+ items: {
+ label: string;
+ level: Level;
+ status: QueryStatus | undefined;
+ result: MetricQuery | undefined;
+ }[];
+ summary: SummaryQuery | null;
+}
+
+const defaultVal = {
+ loading: false,
+ // if it is completely contributed by individuals, hidden organizations section
+ items: [],
+ summary: null,
+};
+
+const dataState = proxy(defaultVal);
+export const ChartsDataContext = createContext(dataState);
+
+const ChartsDataProvider: React.FC = ({ children }) => {
+ const topicType = useQueryMetricType();
+ if (topicType === 'collaboration') {
+ return {children};
+ } else {
+ return (
+ {children}
+ );
+ }
+};
+
+const ChartsDataProviderCollab: React.FC = ({
+ children,
+}) => {
+ const proxyState = useRef(dataState).current;
+ const snap = useSnapshot(chartUserSettingState);
+ const repoType = snap.repoType;
+ const queryClient = useQueryClient();
+ const { timeStart, timeEnd } = useQueryDateRange();
+ const { compareItems } = useCompareItems();
+
+ useQueries({
+ queries: compareItems.map(({ label, level }) => {
+ const variables = {
+ label,
+ level,
+ start: timeStart,
+ end: timeEnd,
+ repoType: level === Level.COMMUNITY ? repoType : '',
+ };
+ return {
+ queryKey: useMetricQuery.getKey(variables),
+ queryFn: useMetricQuery.fetcher(client, variables),
+ };
+ }),
+ });
+
+ useSummaryQuery(
+ client,
+ { start: timeStart, end: timeEnd },
+ {
+ onSuccess(e) {
+ proxyState.summary = e;
+ },
+ }
+ );
+
+ const items = compareItems
+ .map(({ label, level }) => {
+ const variables = {
+ label,
+ level,
+ start: timeStart,
+ end: timeEnd,
+ repoType: level === Level.COMMUNITY ? repoType : '',
+ };
+ const key = useMetricQuery.getKey(variables);
+ return {
+ label,
+ level,
+ status: queryClient.getQueryState(key)?.status,
+ result: queryClient.getQueryData(key),
+ };
+ })
+ .filter((i) => i.status !== 'error');
+
+ const isLoading = items.some((i) => i.status === 'loading');
+
+ // scroll to url hash element
+ usePageLoadHashScroll(isLoading);
+
+ useEffect(() => {
+ proxyState.loading = isLoading;
+ if (!isLoading) {
+ proxyState.items = items;
+ }
+ }, [isLoading, items, proxyState]);
+
+ return (
+
+ {children}
+
+ );
+};
+const ChartsDataProviderContributor: React.FC = ({
+ children,
+}) => {
+ const proxyState = useRef(dataState).current;
+ const snap = useSnapshot(chartUserSettingState);
+ const repoType = snap.repoType;
+ const queryClient = useQueryClient();
+ const { timeStart, timeEnd } = useQueryDateRange();
+ const { compareItems } = useCompareItems();
+
+ useQueries({
+ queries: compareItems.map(({ label, level }) => {
+ const variables = {
+ label,
+ level,
+ start: timeStart,
+ end: timeEnd,
+ repoType: level === Level.COMMUNITY ? repoType : '',
+ };
+ return {
+ queryKey: useMetricContributorQuery.getKey(variables),
+ queryFn: useMetricContributorQuery.fetcher(client, variables),
+ };
+ }),
+ });
+
+ // useSummaryQuery(
+ // client,
+ // { start: timeStart, end: timeEnd },
+ // {
+ // onSuccess(e) {
+ // proxyState.summary = e;
+ // },
+ // }
+ // );
+
+ const items = compareItems
+ .map(({ label, level }) => {
+ const variables = {
+ label,
+ level,
+ start: timeStart,
+ end: timeEnd,
+ repoType: level === Level.COMMUNITY ? repoType : '',
+ };
+ const key = useMetricContributorQuery.getKey(variables);
+ return {
+ label,
+ level,
+ status: queryClient.getQueryState(key)?.status,
+ result: queryClient.getQueryData(key),
+ };
+ })
+ .filter((i) => i.status !== 'error');
+
+ const isLoading = items.some((i) => i.status === 'loading');
+
+ // scroll to url hash element
+ usePageLoadHashScroll(isLoading);
+
+ useEffect(() => {
+ proxyState.loading = isLoading;
+ if (!isLoading) {
+ proxyState.items = items;
+ }
+ }, [isLoading, items, proxyState]);
+
+ return (
+
+ {children}
+
+ );
+};
+export default ChartsDataProvider;
diff --git a/apps/web/src/modules/developer/context/SideBarContext.tsx b/apps/web/src/modules/developer/context/SideBarContext.tsx
new file mode 100644
index 000000000..e23f7f23b
--- /dev/null
+++ b/apps/web/src/modules/developer/context/SideBarContext.tsx
@@ -0,0 +1,29 @@
+import React, { createContext, useContext, PropsWithChildren } from 'react';
+
+export interface SideBarActiveId {
+ topicId: string;
+ menuId: string;
+ subMenuId: string;
+}
+
+export const DEFAULT_CONFIG: SideBarActiveId = {
+ topicId: '',
+ menuId: '',
+ subMenuId: '',
+};
+
+export const SideBarContext = createContext(DEFAULT_CONFIG);
+
+export function useSideBarContext() {
+ return useContext(SideBarContext);
+}
+
+export const SideBarContextProvider: React.FC<
+ PropsWithChildren<{
+ value: SideBarActiveId;
+ }>
+> = ({ value, children }) => {
+ return (
+ {children}
+ );
+};
diff --git a/apps/web/src/modules/developer/context/StatusContext.tsx b/apps/web/src/modules/developer/context/StatusContext.tsx
new file mode 100644
index 000000000..00ba5f4b7
--- /dev/null
+++ b/apps/web/src/modules/developer/context/StatusContext.tsx
@@ -0,0 +1,42 @@
+import React, { createContext, useContext, PropsWithChildren } from 'react';
+import { StatusVerifyQuery } from '@oss-compass/graphql';
+import { Level } from '../constant';
+
+type Item = Pick<
+ StatusVerifyQuery['analysisStatusVerify'],
+ 'label' | 'status' | 'shortCode' | 'collections'
+>;
+
+export type VerifiedLabelItem = {
+ [K in keyof Item]-?: NonNullable- ;
+} & { level: Level };
+
+export interface ConfigValue {
+ status: string;
+ isLoading: boolean;
+ notFound: boolean;
+ verifiedItems: VerifiedLabelItem[];
+}
+
+export const DEFAULT_CONFIG: ConfigValue = {
+ status: '',
+ isLoading: false,
+ notFound: false,
+ verifiedItems: [],
+};
+
+export const StatusContext = createContext(DEFAULT_CONFIG);
+
+export const StatusContextProvider: React.FC<
+ PropsWithChildren<{
+ value: ConfigValue;
+ }>
+> = ({ value, children }) => {
+ return (
+ {children}
+ );
+};
+
+export function useStatusContext() {
+ return useContext(StatusContext);
+}
diff --git a/apps/web/src/modules/developer/context/index.ts b/apps/web/src/modules/developer/context/index.ts
new file mode 100644
index 000000000..44d9319ab
--- /dev/null
+++ b/apps/web/src/modules/developer/context/index.ts
@@ -0,0 +1 @@
+export * from './StatusContext';
diff --git a/apps/web/src/modules/developer/hooks/dateRange.test.ts b/apps/web/src/modules/developer/hooks/dateRange.test.ts
new file mode 100644
index 000000000..32c9abe4f
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/dateRange.test.ts
@@ -0,0 +1,14 @@
+import { isDateRange } from './useQueryDateRange';
+
+describe('dateRange', () => {
+ it('isDateRange', function () {
+ expect(isDateRange('2000-01-01~2023-05-06')).toBe(false);
+ expect(isDateRange('2000-01-01 2023-05-06')).toBe(false);
+ expect(isDateRange('2000-01-01')).toBe(false);
+ expect(isDateRange('')).toBe(false);
+ expect(isDateRange('2000-01-01 ~ 2023-05-06')).toEqual({
+ start: new Date('2000-01-01'),
+ end: new Date('2023-05-06'),
+ });
+ });
+});
diff --git a/apps/web/src/modules/developer/hooks/useCompareItems.ts b/apps/web/src/modules/developer/hooks/useCompareItems.ts
new file mode 100644
index 000000000..eb3c8d8f8
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useCompareItems.ts
@@ -0,0 +1,20 @@
+import { getPathname, compareIdsJoin } from '@common/utils';
+import { useStatusContext } from '@modules/developer/context';
+import { Level } from '@modules/developer/constant';
+
+const useCompareItems = () => {
+ const { verifiedItems } = useStatusContext();
+
+ const items = verifiedItems.map(
+ ({ label, level, shortCode, collections }) => {
+ const name = level === Level.REPO ? getPathname(label) : label;
+ return { label, level, shortCode, name, collections };
+ }
+ );
+
+ const ids = items.map((i) => i.shortCode);
+
+ return { compareItems: items, compareSlugs: compareIdsJoin(ids) };
+};
+
+export default useCompareItems;
diff --git a/apps/web/src/modules/developer/hooks/useDatePickerFormat.ts b/apps/web/src/modules/developer/hooks/useDatePickerFormat.ts
new file mode 100644
index 000000000..fcd29b89d
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useDatePickerFormat.ts
@@ -0,0 +1,9 @@
+import formatDistanceStrict from 'date-fns/formatDistanceStrict';
+import useQueryDateRange from './useQueryDateRange';
+
+const useDatePickerFormat = () => {
+ const { timeStart, timeEnd } = useQueryDateRange();
+ return formatDistanceStrict(timeStart, timeEnd);
+};
+
+export default useDatePickerFormat;
diff --git a/apps/web/src/modules/developer/hooks/useExtractShortIds.ts b/apps/web/src/modules/developer/hooks/useExtractShortIds.ts
new file mode 100644
index 000000000..60719fb38
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useExtractShortIds.ts
@@ -0,0 +1,17 @@
+import { useMemo } from 'react';
+import { useRouter } from 'next/router';
+import head from 'lodash/head';
+import { compareIdsSplit } from '@common/utils/links';
+
+const useExtractShortIds = () => {
+ const router = useRouter();
+ const slugs = router.query.slugs!;
+ const shortIds = useMemo(() => {
+ const idString = Array.isArray(slugs) ? head(slugs) : slugs;
+ return compareIdsSplit(idString);
+ }, [slugs]);
+
+ return { shortIds };
+};
+
+export default useExtractShortIds;
diff --git a/apps/web/src/modules/developer/hooks/useExtractUrlLabels.tsx b/apps/web/src/modules/developer/hooks/useExtractUrlLabels.tsx
new file mode 100644
index 000000000..0438419b9
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useExtractUrlLabels.tsx
@@ -0,0 +1,44 @@
+import { useMemo } from 'react';
+import uniq from 'lodash/uniq';
+import { useRouter } from 'next/router';
+import { Level } from '@modules/developer/constant';
+import { getPathname } from '@common/utils';
+
+function matchUrl(label: string) {
+ // const reg =
+ // /(http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?/;
+ if (label && new RegExp(/^\S*$/).test(label)) {
+ return label;
+ }
+ return false;
+}
+
+function formatToArray(value: string | string[]) {
+ if (typeof value === 'string') {
+ return [value].filter(matchUrl);
+ }
+ return Array.isArray(value) ? uniq(value).filter(matchUrl) : [];
+}
+
+const useUrlLabels = () => {
+ const router = useRouter();
+ const level = router.query.level as Level;
+
+ const labels = useMemo(() => {
+ const values = router.query.label;
+ return formatToArray(values!);
+ }, [router.query.label]);
+
+ const urlLabels = useMemo(() => {
+ return [
+ ...labels.map((label) => ({
+ label,
+ level,
+ })),
+ ];
+ }, [level, labels]);
+
+ return { urlLabels };
+};
+
+export default useUrlLabels;
diff --git a/apps/web/src/modules/developer/hooks/useGetLineOption.tsx b/apps/web/src/modules/developer/hooks/useGetLineOption.tsx
new file mode 100644
index 000000000..569553ef9
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useGetLineOption.tsx
@@ -0,0 +1,104 @@
+import { GenChartOptions } from '@modules/developer/type';
+import {
+ getColorWithLabel,
+ getLineOption,
+ getTooltipsFormatter,
+ legendFormat,
+ line,
+ summaryLine,
+} from '@common/options';
+import { useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { toHundredMark } from '@common/transform/transHundredMarkSystem';
+import { chartUserSettingState } from '@modules/developer/store';
+
+/**
+ * @deprecated use useOptionBuilderFns instead
+ */
+const useGetLineOption = (opt?: {
+ enableDataFormat?: boolean;
+ defaultOnePointSystem?: boolean;
+ defaultShowAvg?: boolean;
+ defaultShowMedian?: boolean;
+ defaultYAxisScale?: boolean;
+ indicators?: string;
+}) => {
+ const {
+ enableDataFormat = false,
+ defaultOnePointSystem = chartUserSettingState.onePointSys,
+ defaultShowAvg = chartUserSettingState.showAvg,
+ defaultShowMedian = chartUserSettingState.showMedian,
+ defaultYAxisScale = chartUserSettingState.yAxisScale,
+ indicators,
+ } = opt || {};
+
+ const { t } = useTranslation();
+ const [onePointSys, setOnePointSys] = useState(defaultOnePointSystem);
+ const [showAvg, setShowAvg] = useState(defaultShowAvg);
+ const [showMedian, setShowMedian] = useState(defaultShowMedian);
+ const [yAxisScale, setYAxisScale] = useState(defaultYAxisScale);
+
+ const getOptions: GenChartOptions = (
+ { xAxis, compareLabels, yResults, summaryMedian, summaryMean },
+ theme
+ ) => {
+ const series = yResults.map(({ label, level, data }) => {
+ const color = getColorWithLabel(theme, label);
+ return line({
+ name: label,
+ data: enableDataFormat ? toHundredMark(!onePointSys, data) : data,
+ color,
+ });
+ });
+
+ if (showMedian) {
+ series.push(
+ summaryLine({
+ id: 'median',
+ name: t('analyze:median'),
+ data: enableDataFormat
+ ? toHundredMark(!onePointSys, summaryMedian)
+ : summaryMedian,
+ color: '#5B8FF9',
+ })
+ );
+ }
+
+ if (showAvg) {
+ series.push(
+ summaryLine({
+ id: 'average',
+ name: t('analyze:average'),
+ data: enableDataFormat
+ ? toHundredMark(!onePointSys, summaryMean)
+ : summaryMean,
+ color: '#F95B5B',
+ })
+ );
+ }
+
+ return getLineOption({
+ xAxisData: xAxis,
+ series,
+ yAxis: { type: 'value', scale: yAxisScale },
+ legend: legendFormat(compareLabels),
+ tooltip: {
+ formatter: getTooltipsFormatter({ compareLabels, indicators }),
+ },
+ });
+ };
+
+ return {
+ getOptions,
+ showAvg,
+ setShowAvg,
+ showMedian,
+ setShowMedian,
+ onePointSys,
+ setOnePointSys,
+ yAxisScale,
+ setYAxisScale,
+ };
+};
+
+export default useGetLineOption;
diff --git a/apps/web/src/modules/developer/hooks/useGetRatioLineOption.tsx b/apps/web/src/modules/developer/hooks/useGetRatioLineOption.tsx
new file mode 100644
index 000000000..aa2f74609
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useGetRatioLineOption.tsx
@@ -0,0 +1,106 @@
+import { GenChartOptions } from '@modules/developer/type';
+import { EChartsOption } from 'echarts';
+import {
+ getColorWithLabel,
+ getLineOption,
+ getTooltipsFormatter,
+ legendFormat,
+ line,
+ summaryLine,
+} from '@common/options';
+import {
+ percentageUnitFormat,
+ percentageValueFormat,
+ checkFormatPercentageValue,
+} from '@common/utils/format';
+import { useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { chartUserSettingState } from '@modules/developer/store';
+
+/**
+ * @deprecated use useOptionBuilderFns instead
+ */
+const useGetRatioLineOption = (opt: {
+ tab: string;
+ defaultShowAvg?: boolean;
+ defaultShowMedian?: boolean;
+ defaultYAxisScale?: boolean;
+}) => {
+ const { t } = useTranslation();
+ const {
+ tab = '1',
+ defaultShowAvg = chartUserSettingState.showAvg,
+ defaultShowMedian = chartUserSettingState.showMedian,
+ defaultYAxisScale = chartUserSettingState.yAxisScale,
+ } = opt;
+
+ const [showAvg, setShowAvg] = useState(defaultShowAvg);
+ const [showMedian, setShowMedian] = useState(defaultShowMedian);
+ const [yAxisScale, setYAxisScale] = useState(defaultYAxisScale);
+
+ const getOptions: GenChartOptions = (
+ { xAxis, compareLabels, yResults, summaryMedian, summaryMean },
+ theme
+ ) => {
+ const series = yResults.map(({ label, level, data }) => {
+ const color = getColorWithLabel(theme, label);
+ return line({
+ name: label,
+ data: tab === '1' ? data.map((v) => percentageValueFormat(v)) : data,
+ color,
+ });
+ });
+
+ if (showMedian) {
+ series.push(
+ summaryLine({
+ id: 'median',
+ name: t('analyze:median'),
+ data: checkFormatPercentageValue(tab === '1', summaryMedian),
+ color: '#5B8FF9',
+ })
+ );
+ }
+ if (showAvg) {
+ series.push(
+ summaryLine({
+ id: 'average',
+ name: t('analyze:average'),
+ data: checkFormatPercentageValue(tab === '1', summaryMean),
+ color: '#F95B5B',
+ })
+ );
+ }
+
+ const ratioYAxis: EChartsOption['yAxis'] = {
+ type: 'value',
+ axisLabel: { formatter: '{value}%' },
+ scale: yAxisScale,
+ };
+
+ return getLineOption({
+ xAxisData: xAxis,
+ series,
+ yAxis: tab === '1' ? ratioYAxis : { type: 'value', scale: yAxisScale },
+ legend: legendFormat(compareLabels),
+ tooltip: {
+ formatter: getTooltipsFormatter({
+ compareLabels,
+ valueFormat: tab === '1' ? percentageUnitFormat : undefined,
+ }),
+ },
+ });
+ };
+
+ return {
+ getOptions,
+ showAvg,
+ setShowAvg,
+ showMedian,
+ setShowMedian,
+ yAxisScale,
+ setYAxisScale,
+ };
+};
+
+export default useGetRatioLineOption;
diff --git a/apps/web/src/modules/developer/hooks/useHandleQueryParams.ts b/apps/web/src/modules/developer/hooks/useHandleQueryParams.ts
new file mode 100644
index 000000000..a8c8dad65
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useHandleQueryParams.ts
@@ -0,0 +1,29 @@
+import { useRouter } from 'next/router';
+
+// 修改或新增查询参数
+const clearEmptyProperties = (obj) => {
+ Object.keys(obj).forEach(function (key) {
+ if (obj[key] === null || obj[key] === undefined || obj[key] === '') {
+ delete obj[key];
+ }
+ });
+};
+export const useHandleQueryParams = () => {
+ const router = useRouter();
+ const handleQueryParams = (newParams) => {
+ const { pathname, query } = router;
+ const newQueryParams = { ...query, ...newParams };
+ clearEmptyProperties(newQueryParams);
+ router.push({
+ pathname,
+ query: newQueryParams,
+ });
+ };
+ const clearAllQueryParams = () => {
+ const { pathname } = router;
+ router.push({
+ pathname,
+ });
+ };
+ return { handleQueryParams, clearAllQueryParams };
+};
diff --git a/apps/web/src/modules/developer/hooks/useIsCurrentUser.tsx b/apps/web/src/modules/developer/hooks/useIsCurrentUser.tsx
new file mode 100644
index 000000000..2d76e0f6b
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useIsCurrentUser.tsx
@@ -0,0 +1,15 @@
+import { useSnapshot } from 'valtio';
+import { userInfoStore } from '@modules/auth/UserInfoStore';
+
+export const useIsCurrentUser = () => {
+ const { currentUser } = useSnapshot(userInfoStore);
+ const loginBinds = currentUser?.loginBinds;
+
+ const isCurrentUser = (name) => {
+ return loginBinds?.find((item) => item.nickname === name);
+ };
+
+ return {
+ isCurrentUser,
+ };
+};
diff --git a/apps/web/src/modules/developer/hooks/useLabelStatus.ts b/apps/web/src/modules/developer/hooks/useLabelStatus.ts
new file mode 100644
index 000000000..dc151a6c4
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useLabelStatus.ts
@@ -0,0 +1,65 @@
+import client from '@common/gqlClient';
+import { StatusVerifyQuery, useStatusVerifyQuery } from '@oss-compass/graphql';
+import { useQueries, useQueryClient } from '@tanstack/react-query';
+import useExtractShortIds from './useExtractShortIds';
+import { VerifiedLabelItem } from '@modules/developer/context';
+
+function nonNullable(value: T): value is NonNullable {
+ return value !== null && value !== undefined;
+}
+
+const useLabelStatus = () => {
+ const queryClient = useQueryClient();
+ const { shortIds } = useExtractShortIds();
+
+ const queries = useQueries({
+ queries: shortIds.map((shortCode) => {
+ return {
+ queryKey: useStatusVerifyQuery.getKey({ shortCode }),
+ queryFn: useStatusVerifyQuery.fetcher(client, { shortCode }),
+ };
+ }),
+ });
+
+ const isLoading = queries.some((query) => query.isLoading);
+
+ const queriesResult = shortIds.map((shortCode) => {
+ const key = useStatusVerifyQuery.getKey({ shortCode });
+ const data = queryClient.getQueryData(key);
+ return { ...data?.analysisStatusVerify };
+ });
+
+ // server verified Items
+ const verifiedItems = queriesResult
+ .filter(nonNullable)
+ .filter((item) => Boolean(item?.label))
+ .filter((item) => {
+ return ['pending', 'progress', 'success'].includes(item?.status || '');
+ }) as VerifiedLabelItem[];
+
+ // single
+ if (verifiedItems.length === 1) {
+ return {
+ isLoading,
+ status: verifiedItems[0].status || '',
+ notFound: false,
+ verifiedItems,
+ };
+ }
+
+ // compare
+ if (verifiedItems.length > 1) {
+ const isSuccess = verifiedItems.every(({ status }) => status === 'success');
+ return {
+ isLoading,
+ status: isSuccess ? 'success' : 'progress',
+ notFound: false,
+ verifiedItems,
+ };
+ }
+
+ // not found
+ return { isLoading, status: '', verifiedItems: [], notFound: true };
+};
+
+export default useLabelStatus;
diff --git a/apps/web/src/modules/developer/hooks/useLevel.ts b/apps/web/src/modules/developer/hooks/useLevel.ts
new file mode 100644
index 000000000..f4529f7d8
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useLevel.ts
@@ -0,0 +1,15 @@
+import { useMemo } from 'react';
+import { useStatusContext } from '@modules/developer/context';
+import { Level } from '@modules/developer/constant';
+
+const useLevel = () => {
+ const { verifiedItems } = useStatusContext();
+ return useMemo(() => {
+ if (verifiedItems.length > 0) {
+ return verifiedItems[0].level;
+ }
+ return Level.REPO;
+ }, [verifiedItems]);
+};
+
+export default useLevel;
diff --git a/apps/web/src/modules/developer/hooks/useMetricQueryData.ts b/apps/web/src/modules/developer/hooks/useMetricQueryData.ts
new file mode 100644
index 000000000..472c8588a
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useMetricQueryData.ts
@@ -0,0 +1,11 @@
+import { useContext } from 'react';
+import { useSnapshot } from 'valtio';
+import { ChartsDataContext } from '@modules/developer/context/ChartsDataProvider';
+
+const useMetricQueryData = () => {
+ const state = useContext(ChartsDataContext);
+ const { loading, items, summary } = useSnapshot(state);
+ return { loading, items, summary };
+};
+
+export default useMetricQueryData;
diff --git a/apps/web/src/modules/developer/hooks/useQueryDateRange.ts b/apps/web/src/modules/developer/hooks/useQueryDateRange.ts
new file mode 100644
index 000000000..ea869e3f0
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useQueryDateRange.ts
@@ -0,0 +1,65 @@
+import { useMemo } from 'react';
+import { useRouter } from 'next/router';
+import { RangeTag, rangeTags, timeRange } from '../constant';
+import useVerifyDetailRangeQuery from '@modules/developer/hooks/useVerifyDetailRangeQuery';
+import useQueryMetricType from '@modules/developer/hooks/useQueryMetricType';
+
+const defaultVal = {
+ range: '6M' as RangeTag,
+ timeStart: timeRange['6M'].start,
+ timeEnd: timeRange['6M'].end,
+};
+const contributorDefaultVal = {
+ range: '6M' as RangeTag,
+ timeStart: timeRange['6M'].start,
+ timeEnd: timeRange['6M'].end,
+};
+export const isDateRange = (range: string) => {
+ if (range.includes(' ~ ')) {
+ const start = range.split(' ~ ')[0];
+ const end = range.split(' ~ ')[1];
+ const re = /^(\d{1,4})(-|\/)(\d{1,2})\2(\d{1,2})$/;
+ const r = start.match(re) && end.match(re);
+ if (r) {
+ return { start: new Date(start), end: new Date(end) };
+ }
+ return false;
+ }
+ return false;
+};
+const useQueryDateRange = () => {
+ const router = useRouter();
+ const range = router.query.range as RangeTag;
+ const { isLoading, data } = useVerifyDetailRangeQuery();
+ const topicType = useQueryMetricType();
+
+ return useMemo(() => {
+ if (
+ topicType === 'contributor' &&
+ (!range || !data?.verifyDetailDataRange?.status)
+ ) {
+ return contributorDefaultVal;
+ } else {
+ if (!range) {
+ return defaultVal;
+ } else if (rangeTags.includes(range)) {
+ return {
+ range,
+ timeStart: timeRange[range].start,
+ timeEnd: timeRange[range].end,
+ };
+ } else if (isDateRange(range)) {
+ const { start, end } = isDateRange(range) as { start: Date; end: Date };
+ return {
+ range,
+ timeStart: start,
+ timeEnd: end,
+ };
+ } else {
+ return defaultVal;
+ }
+ }
+ }, [range, topicType, isLoading, data]);
+};
+
+export default useQueryDateRange;
diff --git a/apps/web/src/modules/developer/hooks/useQueryMetricType.ts b/apps/web/src/modules/developer/hooks/useQueryMetricType.ts
new file mode 100644
index 000000000..e728148bd
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useQueryMetricType.ts
@@ -0,0 +1,24 @@
+import { useMemo } from 'react';
+import { useRouter } from 'next/router';
+
+const defaultVal = 'collaboration';
+
+export type MetricType = 'collaboration' | 'contributor' | null;
+
+const typeList = ['collaboration', 'contributor'];
+
+const useQueryMetricType = () => {
+ const router = useRouter();
+ const metricType = router.query.metricType as MetricType;
+ return useMemo(() => {
+ if (!metricType) {
+ return defaultVal;
+ } else if (typeList.includes(metricType)) {
+ return metricType;
+ } else {
+ return defaultVal;
+ }
+ }, [metricType]);
+};
+
+export default useQueryMetricType;
diff --git a/apps/web/src/modules/developer/hooks/useSwitchMetricType.ts b/apps/web/src/modules/developer/hooks/useSwitchMetricType.ts
new file mode 100644
index 000000000..2e529f7cd
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useSwitchMetricType.ts
@@ -0,0 +1,22 @@
+import qs from 'query-string';
+import { useRouter } from 'next/router';
+
+const useSwitchMetricType = () => {
+ const route = useRouter();
+
+ const switchMetricType = async (t: string) => {
+ const pathname = window.location.pathname;
+ const hash = window.location.hash;
+
+ const searchResult = qs.parse(window.location.search) || {};
+ searchResult.metricType = t;
+ const newSearch = qs.stringify(searchResult);
+
+ const url = `${pathname}?${newSearch}${hash}`;
+ await route.replace(url, undefined, { scroll: false });
+ };
+
+ return { switchMetricType };
+};
+
+export default useSwitchMetricType;
diff --git a/apps/web/src/modules/developer/hooks/useTopicNavbarScroll.ts b/apps/web/src/modules/developer/hooks/useTopicNavbarScroll.ts
new file mode 100644
index 000000000..6f3af1693
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useTopicNavbarScroll.ts
@@ -0,0 +1,103 @@
+import { useEffect, useMemo, useState } from 'react';
+import { throttle } from 'lodash';
+
+const isCardInView = (rect: DOMRect) => {
+ const { top, bottom } = rect;
+ return (
+ top >= 0 &&
+ bottom <= (window.innerHeight || document.documentElement.clientHeight)
+ );
+};
+
+const lookupSiblingTagH1 = (element: HTMLElement) => {
+ let sibling: Node | null = element.previousSibling;
+ while (sibling) {
+ if (
+ sibling.nodeType === 1 &&
+ (sibling as HTMLElement).tagName.toLowerCase() === 'h1'
+ ) {
+ return sibling as HTMLElement;
+ }
+ sibling = sibling.previousSibling;
+ }
+
+ return null;
+};
+
+function lookupParentSiblingH2(element: HTMLElement): HTMLElement | null {
+ let sibling: Node | null = element.previousSibling;
+
+ while (sibling) {
+ if (
+ sibling.nodeType === 1 &&
+ (sibling as HTMLElement).tagName.toLowerCase() === 'h2'
+ ) {
+ return sibling as HTMLElement;
+ }
+ sibling = sibling.previousSibling;
+ }
+
+ const parentElement = element.parentElement;
+ if (parentElement) {
+ return lookupParentSiblingH2(parentElement);
+ }
+
+ return null;
+}
+
+const useTopicNavbarScroll = () => {
+ const [inViewCardId, setInViewCardId] = useState('');
+
+ useEffect(() => {
+ const scrollEventListener = throttle(() => {
+ if (document.documentElement.scrollTop < 190) {
+ setInViewCardId('');
+ return;
+ }
+
+ const list = document.querySelectorAll('.base-card');
+ for (let i = 0; i < list.length; i++) {
+ const cardEl = list[i];
+ const rect = cardEl.getBoundingClientRect();
+
+ if (isCardInView(rect)) {
+ if (cardEl?.id) setInViewCardId(cardEl?.id);
+ return;
+ }
+ }
+ }, 500);
+
+ document.addEventListener('scroll', scrollEventListener);
+ return () => {
+ document.removeEventListener('scroll', scrollEventListener);
+ };
+ }, []);
+
+ return useMemo(() => {
+ if (!inViewCardId) {
+ return { topicTitle: '', subTitle: '' };
+ }
+
+ if (inViewCardId === 'topic_overview') {
+ return { topicTitle: 'Overview', subTitle: '' };
+ }
+
+ let topicTitle = '';
+ let subTitle = '';
+
+ const card = document.querySelector('#' + inViewCardId) as HTMLElement;
+ const subIdNode = lookupParentSiblingH2(card);
+ if (subIdNode) {
+ subTitle = subIdNode.innerText.replace('#', '').trim();
+
+ const topicNode = lookupSiblingTagH1(subIdNode);
+ if (topicNode) {
+ topicTitle = topicNode.innerText.replace('#', '').trim();
+ }
+ }
+
+ return { topicTitle, subTitle };
+ }, [inViewCardId]);
+};
+
+export default useTopicNavbarScroll;
diff --git a/apps/web/src/modules/developer/hooks/useVerifyDateRange.ts b/apps/web/src/modules/developer/hooks/useVerifyDateRange.ts
new file mode 100644
index 000000000..7530502ae
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useVerifyDateRange.ts
@@ -0,0 +1,51 @@
+import { useMemo } from 'react';
+import { useRouter } from 'next/router';
+import { RangeTag, rangeTags, timeRange } from '@modules/developer/constant';
+import useVerifyDetailRangeQuery from '@modules/developer/hooks/useVerifyDetailRangeQuery';
+
+const contributorDefaultVal = {
+ range: '6M' as RangeTag,
+ timeStart: timeRange['6M'].start,
+ timeEnd: timeRange['6M'].end,
+};
+export const isDateRange = (range: string) => {
+ if (range.includes(' ~ ')) {
+ const start = range.split(' ~ ')[0];
+ const end = range.split(' ~ ')[1];
+ const re = /^(\d{1,4})(-|\/)(\d{1,2})\2(\d{1,2})$/;
+ const r = start.match(re) && end.match(re);
+ if (r) {
+ return { start: new Date(start), end: new Date(end) };
+ }
+ return false;
+ }
+ return false;
+};
+const useVerifyDateRange = () => {
+ const router = useRouter();
+ const range = router.query.range as RangeTag;
+ const { isLoading, data } = useVerifyDetailRangeQuery();
+
+ return useMemo(() => {
+ if (!range || !data) {
+ return contributorDefaultVal;
+ } else if (rangeTags.includes(range)) {
+ return {
+ range,
+ timeStart: timeRange[range].start,
+ timeEnd: timeRange[range].end,
+ };
+ } else if (isDateRange(range)) {
+ const { start, end } = isDateRange(range) as { start: Date; end: Date };
+ return {
+ range,
+ timeStart: start,
+ timeEnd: end,
+ };
+ } else {
+ return contributorDefaultVal;
+ }
+ }, [range, isLoading, data]);
+};
+
+export default useVerifyDateRange;
diff --git a/apps/web/src/modules/developer/hooks/useVerifyDetailRangeQuery.ts b/apps/web/src/modules/developer/hooks/useVerifyDetailRangeQuery.ts
new file mode 100644
index 000000000..9ecbc0bb0
--- /dev/null
+++ b/apps/web/src/modules/developer/hooks/useVerifyDetailRangeQuery.ts
@@ -0,0 +1,26 @@
+import client from '@common/gqlClient';
+import { useVerifyDetailDataRangeQuery } from '@oss-compass/graphql';
+import useExtractShortIds from './useExtractShortIds';
+import { RangeTag, rangeTags, timeRange } from '@modules/developer/constant';
+
+const useVerifyDetailRangeQuery = () => {
+ const { shortIds } = useExtractShortIds();
+ const beginDate = timeRange['1Y'].start;
+ const endDate = timeRange['1Y'].end;
+
+ const { data, isLoading } = useVerifyDetailDataRangeQuery(
+ client,
+ {
+ shortCode: shortIds[0],
+ beginDate,
+ endDate,
+ },
+ {
+ staleTime: 300000, // 5 minutes
+ }
+ );
+
+ return { isLoading, data };
+};
+
+export default useVerifyDetailRangeQuery;
diff --git a/apps/web/src/modules/developer/index.tsx b/apps/web/src/modules/developer/index.tsx
new file mode 100644
index 000000000..93a98de76
--- /dev/null
+++ b/apps/web/src/modules/developer/index.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import LegacyLabelRedirect from './components/Container/LegacyLabelRedirect';
+import AnalyzeContainer from './components/Container/AnalyzeContainer';
+import HeaderWithFilterBar from './components/HeaderWithFitlerBar';
+import { Main, Content } from '@common/components/Layout';
+import Footer from '@common/components/Footer';
+import SideBar from './components/SideBar';
+import DataView from './DataView';
+
+const Analyze = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Analyze;
diff --git a/apps/web/src/modules/developer/options/ChartDataProvider.tsx b/apps/web/src/modules/developer/options/ChartDataProvider.tsx
new file mode 100644
index 000000000..2346bd6cd
--- /dev/null
+++ b/apps/web/src/modules/developer/options/ChartDataProvider.tsx
@@ -0,0 +1,110 @@
+import React, { ReactNode } from 'react';
+import { Trans } from 'react-i18next';
+import { useTranslation } from 'next-i18next';
+import transMetricToAxis from '@common/transform/transMetricToAxis';
+import transSummaryToAxis from '@common/transform/transSummaryToAxis';
+import useMetricQueryData from '@modules/developer/hooks/useMetricQueryData';
+import LinkLegacy from '@common/components/LinkLegacy';
+import { chartUserSettingState } from '@modules/developer/store';
+import { useSnapshot } from 'valtio';
+import {
+ TransOpt,
+ DataContainerResult,
+ YResult,
+} from '@modules/developer/type';
+import { isNull, isUndefined } from 'lodash';
+import { DebugLogger } from '@common/debug';
+
+const logger = new DebugLogger('ChartDataContainer');
+
+const isEmptyData = (result: YResult[]) => {
+ return result.every((r) => {
+ return r.data.every((i) => {
+ return isNull(i) || isUndefined(i);
+ });
+ });
+};
+
+const Empty = () => {
+ const { t, i18n } = useTranslation();
+ return (
+
+
+ {t('analyze:there_is_currently_no_data_in_the_chart')}
+
+
+
+ ),
+ }}
+ />
+
+
+ );
+};
+
+export const ChartDataProvider: React.FC<{
+ _tracing?: string;
+ tansOpts: TransOpt;
+ children:
+ | ((args: {
+ loading: boolean;
+ isEmpty: boolean;
+ result: DataContainerResult;
+ }) => ReactNode)
+ | ReactNode;
+}> = ({ children, tansOpts, _tracing }) => {
+ const data = useMetricQueryData();
+ const loading = data?.loading;
+ const snap = useSnapshot(chartUserSettingState);
+ const { xAxis, yResults } = transMetricToAxis(
+ data?.items,
+ tansOpts,
+ snap.repoType
+ );
+ const { summaryMean, summaryMedian } = transSummaryToAxis(
+ data?.summary,
+ xAxis,
+ tansOpts.summaryKey
+ );
+ const compareLabels = yResults.map((i) => i.label);
+ const isCompare = yResults.length > 1;
+ const isEmpty = isEmptyData(yResults);
+
+ if (_tracing) {
+ logger.debug(_tracing, { loading, data });
+ }
+
+ if (isEmpty && !loading) {
+ return ;
+ }
+
+ const childProps = {
+ loading,
+ isEmpty,
+ result: {
+ compareLabels,
+ isCompare,
+ xAxis,
+ yResults,
+ summaryMean,
+ summaryMedian,
+ },
+ };
+ return (
+ <>{typeof children === 'function' ? children(childProps) : children}>
+ );
+};
diff --git a/apps/web/src/modules/developer/options/ChartOptionProvider.tsx b/apps/web/src/modules/developer/options/ChartOptionProvider.tsx
new file mode 100644
index 000000000..0493b1d49
--- /dev/null
+++ b/apps/web/src/modules/developer/options/ChartOptionProvider.tsx
@@ -0,0 +1,36 @@
+import React, { ReactNode } from 'react';
+import { EChartsOption } from 'echarts';
+import { useSnapshot } from 'valtio';
+import { ChartThemeState, chartThemeState } from '@modules/developer/store';
+import { DataContainerResult } from '@modules/developer/type';
+import { DebugLogger } from '@common/debug';
+
+const logger = new DebugLogger('ChartOptionProvider');
+
+export const ChartOptionProvider = ({
+ _tracing,
+ data,
+ optionFn,
+ render,
+}: {
+ data: DataContainerResult;
+ optionFn: (
+ data: DataContainerResult,
+ theme: ChartThemeState
+ ) => EChartsOption;
+ render: ((args: { option: EChartsOption }) => ReactNode) | ReactNode;
+ _tracing?: string;
+}) => {
+ const theme = useSnapshot(chartThemeState) as ChartThemeState;
+ const echartsOpts = optionFn(data, theme);
+
+ if (_tracing) {
+ logger.debug(_tracing, echartsOpts);
+ }
+
+ return (
+ <>
+ {typeof render === 'function' ? render({ option: echartsOpts }) : render}
+ >
+ );
+};
diff --git a/apps/web/src/modules/developer/options/builder/getCompareStyleBuilder.ts b/apps/web/src/modules/developer/options/builder/getCompareStyleBuilder.ts
new file mode 100644
index 000000000..8fce7218a
--- /dev/null
+++ b/apps/web/src/modules/developer/options/builder/getCompareStyleBuilder.ts
@@ -0,0 +1,13 @@
+import type { getBuilderOptionFn } from '@modules/developer/options/useOptionBuilderFns';
+
+export const getCompareStyleBuilder: getBuilderOptionFn<{
+ indicators?: boolean;
+}> =
+ ({ indicators = false }) =>
+ (pre, data) => {
+ if (!data.isCompare) {
+ pre.grid = { ...pre.grid, top: indicators ? 50 : 10 };
+ pre.legend = { show: false };
+ }
+ return pre;
+ };
diff --git a/apps/web/src/modules/developer/options/builder/getIndicatorsBuilder.ts b/apps/web/src/modules/developer/options/builder/getIndicatorsBuilder.ts
new file mode 100644
index 000000000..7ab265c39
--- /dev/null
+++ b/apps/web/src/modules/developer/options/builder/getIndicatorsBuilder.ts
@@ -0,0 +1,23 @@
+import type { getBuilderOptionFn } from '@modules/developer/options/useOptionBuilderFns';
+import { getYAxisWithUnit } from '@common/options';
+
+export const getIndicatorsBuilder: getBuilderOptionFn<{
+ yAxisScale: boolean;
+ language: string;
+ indicatorsText: string;
+ unitText: string;
+}> =
+ ({ language, yAxisScale, indicatorsText, unitText }) =>
+ (pre, data) => {
+ return {
+ ...pre,
+ ...getYAxisWithUnit({
+ result: data,
+ indicators: indicatorsText,
+ unit: unitText,
+ namePaddingLeft: language === 'zh' ? 0 : 35,
+ shortenYaxisNumberLabel: true,
+ scale: yAxisScale,
+ }),
+ };
+ };
diff --git a/apps/web/src/modules/developer/options/builder/getLineBuilder.ts b/apps/web/src/modules/developer/options/builder/getLineBuilder.ts
new file mode 100644
index 000000000..16d6e7927
--- /dev/null
+++ b/apps/web/src/modules/developer/options/builder/getLineBuilder.ts
@@ -0,0 +1,81 @@
+import {
+ getColorWithLabel,
+ getLineOption,
+ getTooltipsFormatter,
+ legendFormat,
+ line,
+ summaryLine,
+} from '@common/options';
+import type { getBuilderOptionFn } from '@modules/developer/options/useOptionBuilderFns';
+import { toHundredMark } from '@common/transform/transHundredMarkSystem';
+import { formatISO } from '@common/utils';
+
+export const getLineBuilder: getBuilderOptionFn<{
+ enableDataFormat?: boolean;
+ onePointSystem?: boolean;
+ indicators?: string;
+ showMedian?: boolean;
+ medianMame: string;
+ showAvg?: boolean;
+ avgName: string;
+ yAxisScale: boolean;
+}> =
+ ({
+ enableDataFormat = false,
+ onePointSystem = false,
+ indicators,
+ showMedian = false,
+ medianMame,
+ showAvg = false,
+ avgName,
+ yAxisScale = true,
+ }) =>
+ (pre, data, theme) => {
+ const { yResults, xAxis, compareLabels, summaryMedian, summaryMean } = data;
+ const series = yResults.map(({ label, level, data }) => {
+ const color = getColorWithLabel(theme, label);
+ return line({
+ name: label,
+ data: enableDataFormat ? toHundredMark(!onePointSystem, data) : data,
+ color,
+ });
+ });
+
+ if (showMedian) {
+ series.push(
+ summaryLine({
+ id: 'median',
+ name: medianMame,
+ data: enableDataFormat
+ ? toHundredMark(!onePointSystem, summaryMedian)
+ : summaryMedian,
+ color: '#5B8FF9',
+ })
+ );
+ }
+
+ if (showAvg) {
+ series.push(
+ summaryLine({
+ id: 'average',
+ name: avgName,
+ data: enableDataFormat
+ ? toHundredMark(!onePointSystem, summaryMean)
+ : summaryMean,
+ color: '#F95B5B',
+ })
+ );
+ }
+
+ const opts = getLineOption({
+ xAxisData: xAxis.map((i) => formatISO(i)),
+ series,
+ yAxis: { type: 'value', scale: yAxisScale },
+ legend: legendFormat(compareLabels),
+ tooltip: {
+ formatter: getTooltipsFormatter({ compareLabels, indicators }),
+ },
+ });
+
+ return { ...pre, ...opts, series };
+ };
diff --git a/apps/web/src/modules/developer/options/builder/getRatioLineBuilder.ts b/apps/web/src/modules/developer/options/builder/getRatioLineBuilder.ts
new file mode 100644
index 000000000..aa6d8b193
--- /dev/null
+++ b/apps/web/src/modules/developer/options/builder/getRatioLineBuilder.ts
@@ -0,0 +1,90 @@
+import { EChartsOption } from 'echarts';
+import { summaryLine } from '@common/options';
+import { LineSeriesOption } from 'echarts';
+import { checkFormatPercentageValue } from '@common/utils/format';
+import type { getBuilderOptionFn } from '@modules/developer/options/useOptionBuilderFns';
+import {
+ getColorWithLabel,
+ getLineOption,
+ legendFormat,
+ line,
+ getTooltipsFormatter,
+} from '@common/options';
+import {
+ percentageValueFormat,
+ percentageUnitFormat,
+} from '@common/utils/format';
+import { formatISO } from '@common/utils';
+
+export const getRatioLineBuilder: getBuilderOptionFn<{
+ showMedian?: boolean;
+ medianMame: string;
+ showAvg?: boolean;
+ avgName: string;
+ isRatio: boolean;
+ yAxisScale: boolean;
+}> =
+ ({
+ showMedian = false,
+ medianMame,
+ showAvg = false,
+ avgName,
+ isRatio = false,
+ yAxisScale = true,
+ }) =>
+ (pre, data, theme) => {
+ const { yResults, xAxis, compareLabels, summaryMedian, summaryMean } = data;
+ const series = yResults.map(({ label, level, data }) => {
+ const color = getColorWithLabel(theme, label);
+ return line({
+ name: label,
+ data: isRatio ? data.map((v) => percentageValueFormat(v)) : data,
+ color,
+ });
+ });
+
+ if (showMedian) {
+ series.push(
+ summaryLine({
+ id: 'median',
+ name: medianMame,
+ data: checkFormatPercentageValue(isRatio, summaryMedian),
+ color: '#5B8FF9',
+ })
+ );
+ }
+
+ if (showAvg) {
+ series.push(
+ summaryLine({
+ id: 'average',
+ name: avgName,
+ data: checkFormatPercentageValue(isRatio, summaryMean),
+ color: '#F95B5B',
+ })
+ );
+ }
+
+ const yAxis: EChartsOption['yAxis'] = isRatio
+ ? {
+ type: 'value',
+ axisLabel: { formatter: '{value}%' },
+ scale: yAxisScale,
+ }
+ : { type: 'value', scale: yAxisScale };
+
+ const opts = getLineOption({
+ xAxisData: xAxis.map((i) => formatISO(i)),
+ series,
+ yAxis,
+ legend: legendFormat(compareLabels),
+ tooltip: {
+ formatter: getTooltipsFormatter({
+ compareLabels,
+ valueFormat: isRatio ? percentageUnitFormat : undefined,
+ }),
+ },
+ });
+
+ return { ...pre, ...opts, series };
+ };
diff --git a/apps/web/src/modules/developer/options/index.ts b/apps/web/src/modules/developer/options/index.ts
new file mode 100644
index 000000000..f34ad4de3
--- /dev/null
+++ b/apps/web/src/modules/developer/options/index.ts
@@ -0,0 +1,10 @@
+export * from './ChartDataProvider';
+export * from './ChartOptionProvider';
+
+export * from './useCardManual';
+export * from './useOptionBuilderFns';
+
+export * from './builder/getRatioLineBuilder';
+export * from './builder/getLineBuilder';
+export * from './builder/getCompareStyleBuilder';
+export * from './builder/getIndicatorsBuilder';
diff --git a/apps/web/src/modules/developer/options/useCardManual.tsx b/apps/web/src/modules/developer/options/useCardManual.tsx
new file mode 100644
index 000000000..964b82038
--- /dev/null
+++ b/apps/web/src/modules/developer/options/useCardManual.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { useState } from 'react';
+import { chartUserSettingState } from '@modules/developer/store';
+
+type Props = {
+ defaultOnePointSystem?: boolean;
+ defaultShowAvg?: boolean;
+ defaultShowMedian?: boolean;
+ defaultYAxisScale?: boolean;
+};
+
+export const useCardManual = (props: Props = {}) => {
+ const {
+ defaultOnePointSystem = chartUserSettingState.onePointSys,
+ defaultShowMedian = chartUserSettingState.showMedian,
+ defaultShowAvg = chartUserSettingState.showAvg,
+ defaultYAxisScale = chartUserSettingState.yAxisScale,
+ } = props;
+
+ const [onePointSys, setOnePointSys] = useState(defaultOnePointSystem);
+ const [showAvg, setShowAvg] = useState(defaultShowAvg);
+ const [showMedian, setShowMedian] = useState(defaultShowMedian);
+ const [yAxisScale, setYAxisScale] = useState(defaultYAxisScale);
+
+ return {
+ onePointSys,
+ setOnePointSys,
+ showAvg,
+ setShowAvg,
+ showMedian,
+ setShowMedian,
+ yAxisScale,
+ setYAxisScale,
+ };
+};
diff --git a/apps/web/src/modules/developer/options/useOptionBuilderFns.ts b/apps/web/src/modules/developer/options/useOptionBuilderFns.ts
new file mode 100644
index 000000000..34c66d53c
--- /dev/null
+++ b/apps/web/src/modules/developer/options/useOptionBuilderFns.ts
@@ -0,0 +1,20 @@
+import React, { useReducer, useCallback } from 'react';
+import { EChartsOption } from 'echarts';
+import { ChartThemeState } from '@modules/developer/store';
+import { DataContainerResult } from '../type';
+
+export type getBuilderOptionFn = (v: T) => BuilderFn;
+
+type BuilderFn = (
+ pre: EChartsOption,
+ data: DataContainerResult,
+ theme: ChartThemeState
+) => EChartsOption;
+
+export const useOptionBuilderFns = (fns: BuilderFn[]) => {
+ return (data: DataContainerResult, theme: ChartThemeState) => {
+ return fns.reduce((pre, current) => {
+ return current(pre, data, theme);
+ }, {});
+ };
+};
diff --git a/apps/web/src/modules/developer/store/chartTheme.ts b/apps/web/src/modules/developer/store/chartTheme.ts
new file mode 100644
index 000000000..91b512e85
--- /dev/null
+++ b/apps/web/src/modules/developer/store/chartTheme.ts
@@ -0,0 +1,31 @@
+import { proxy } from 'valtio';
+import { devtools } from 'valtio/utils';
+
+export type ChartThemeState = {
+ color: { label: string; paletteIndex: number }[];
+};
+
+export const chartThemeState = proxy({
+ color: [],
+});
+devtools(chartThemeState, { name: 'chartThemeState', enabled: true });
+
+export const initThemeColor = (
+ payload: { label: string; paletteIndex: number }[]
+) => {
+ chartThemeState.color = payload;
+};
+
+export const updateThemeColor = (payload: {
+ label: string;
+ paletteIndex: number;
+}) => {
+ const { color } = chartThemeState;
+ const { label, paletteIndex } = payload;
+ chartThemeState.color = color.map((c) => {
+ if (c.label === label) {
+ c.paletteIndex = paletteIndex;
+ }
+ return c;
+ });
+};
diff --git a/apps/web/src/modules/developer/store/chartUserSetting.ts b/apps/web/src/modules/developer/store/chartUserSetting.ts
new file mode 100644
index 000000000..828c8e599
--- /dev/null
+++ b/apps/web/src/modules/developer/store/chartUserSetting.ts
@@ -0,0 +1,46 @@
+import { proxy, subscribe } from 'valtio';
+import { CommunityRepoType } from '@common/constant';
+import isBrowser from '@common/utils/isBrowser';
+
+const KEY = 'analyze.setting.chart';
+
+const localSet = (content: string) => {
+ return localStorage.setItem(KEY, content);
+};
+
+const localGet = () => {
+ try {
+ if (!isBrowser()) return null;
+
+ const str = localStorage.getItem(KEY);
+ const local = JSON.parse(str!);
+ local?.repoType && (local.repoType = 'software-artifact');
+ return local;
+ } catch (e) {
+ console.log(e);
+ return null;
+ }
+};
+
+type UserSettingProps = {
+ showAvg: boolean;
+ showMedian: boolean;
+ onePointSys: boolean;
+ repoType: CommunityRepoType;
+ // https://echarts.apache.org/en/option.html#yAxis.scale
+ yAxisScale: boolean;
+};
+
+export const chartUserSettingState = proxy(
+ localGet() || {
+ showAvg: false,
+ showMedian: false,
+ onePointSys: false,
+ repoType: 'software-artifact',
+ yAxisScale: true,
+ }
+);
+
+subscribe(chartUserSettingState, () => {
+ localSet(JSON.stringify(chartUserSettingState));
+});
diff --git a/apps/web/src/modules/developer/store/index.ts b/apps/web/src/modules/developer/store/index.ts
new file mode 100644
index 000000000..5927bfda2
--- /dev/null
+++ b/apps/web/src/modules/developer/store/index.ts
@@ -0,0 +1,2 @@
+export * from './chartTheme';
+export * from './chartUserSetting';
diff --git a/apps/web/src/modules/developer/type.ts b/apps/web/src/modules/developer/type.ts
new file mode 100644
index 000000000..de1e75b0e
--- /dev/null
+++ b/apps/web/src/modules/developer/type.ts
@@ -0,0 +1,56 @@
+import { Level } from '@modules/developer/constant';
+import { ChartThemeState } from '@modules/developer/store';
+import { EChartsOption } from 'echarts';
+
+export interface TabOption {
+ label: string;
+ value: string;
+}
+
+export interface TransOpt {
+ xKey: string;
+ yKey: string;
+ legendName: string;
+ summaryKey: string;
+}
+
+export interface DataContainerResult {
+ isCompare: boolean;
+ compareLabels: string[];
+ xAxis: string[];
+ yResults: YResult[];
+ summaryMean: (number | string)[];
+ summaryMedian: (number | string)[];
+}
+
+export interface GenChartData {
+ isCompare: boolean;
+ compareLabels: string[];
+ xAxis: string[];
+ yResults: YResult[];
+ summaryMean: (number | string)[];
+ summaryMedian: (number | string)[];
+}
+
+export type GenChartOptions = (
+ input: DataContainerResult,
+ theme?: DeepReadonly
+) => EChartsOption;
+
+export interface TransResult {
+ xAxis: string[];
+ yResults: YResult[];
+}
+
+export interface SummaryResult {
+ summaryMean: (number | string)[];
+ summaryMedian: (number | string)[];
+}
+
+export interface YResult {
+ label: string;
+ level: Level;
+ legendName: string;
+ key: string;
+ data: any[];
+}
diff --git a/apps/web/src/pages/developer/[slugs].tsx b/apps/web/src/pages/developer/[slugs].tsx
new file mode 100644
index 000000000..995d60bd6
--- /dev/null
+++ b/apps/web/src/pages/developer/[slugs].tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { GetServerSideProps } from 'next';
+import Developer from '@modules/developer';
+import getLocalesFile from '@common/utils/getLocalesFile';
+
+export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
+ return {
+ props: {
+ ...(await getLocalesFile(req.cookies, ['analyze', 'metrics_models'])),
+ },
+ };
+};
+
+const DeveloperPage = () => {
+ return ;
+};
+
+export default DeveloperPage;