Skip to content

Commit

Permalink
feat: add trends tab (#77)
Browse files Browse the repository at this point in the history
> 参照 https://npmtrends.com/next 实现下载趋势对比, see #75 
1. 默认全量展示 2020 年开始相关数据
2. 支持最多5个包横向对比


![image](https://github.com/cnpm/cnpmweb/assets/5574625/d700f580-a32c-4554-bc14-f741b9393558)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a "Download Trends" tab to provide insights into the
download trends of packages.
- Added a new `Trends` component that allows users to view and compare
the total downloads of multiple packages over different years.
- Implemented functionality to search for packages and display their
total download statistics on the Trends page.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
elrrrrrrr authored Mar 29, 2024
1 parent 909a82c commit cd8ccb7
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 4 deletions.
4 changes: 4 additions & 0 deletions src/components/CustomTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const presetTabs = [
name: '版本列表',
key: 'versions',
},
{
name: '下载趋势',
key: 'trends',
},
];

export default function CustomTabs({
Expand Down
97 changes: 93 additions & 4 deletions src/components/RecentDownloads.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { useRecentDownloads } from '@/hooks/useRecentDownloads';
import { useRecentDownloads, useTotalDownloads } from '@/hooks/useRecentDownloads';
import React from 'react';
import { Empty } from 'antd';

import { Line } from 'react-chartjs-2';
import "chart.js/auto";
import { scales } from 'chart.js/auto';


type RecentDownloadsContentProps = {
pkgName: string;
version: string;
};

type TotalDownloadsProps = {
pkgNameList: string[];
};

const COLOR_LIST = [
'rgb(53, 162, 235, 0.7)',
'rgb(255, 99, 132, 0.7)',
'rgb(255, 205, 86, 0.7)',
'rgb(75, 192, 192, 0.7)',
'rgb(153, 102, 255, 0.7)',
];

export function RecentDownloads({ pkgName, version }: RecentDownloadsContentProps) {
const { data: res, isLoading } = useRecentDownloads(pkgName, version);
if (isLoading || !res) {
Expand Down Expand Up @@ -54,3 +63,83 @@ export function RecentDownloads({ pkgName, version }: RecentDownloadsContentProp
/>
);
}

export function TotalDownloads({ pkgNameList }: TotalDownloadsProps) {
// 通过 swr 来进行缓存,由于 hook 限制,不能直接 map 循环
// 另一种方式是在 useTotalDownloads 中自行维护 cache 逻辑
// 由于希望添加 pkgName 时页面不额外刷新,先采用这种方式
const [pkg1, pkg2, pkg3, pkg4, pkg5] = pkgNameList;
const {data: pkg1Data} = useTotalDownloads(pkg1);
const {data: pkg2Data} = useTotalDownloads(pkg2);
const {data: pkg3Data} = useTotalDownloads(pkg3);
const {data: pkg4Data} = useTotalDownloads(pkg4);
const {data: pkg5Data} = useTotalDownloads(pkg5);

const res = [pkg1Data, pkg2Data, pkg3Data, pkg4Data, pkg5Data];

if (!res.find(_ => _?.downloads)) {
return <Empty description="暂无数据" />;
}

return (
<Line
options={{
scales: {
x: {
display: true,
},
},
elements: {
line: {
tension: 0.4,
},
point: {
radius: 0,
},
},
plugins: {
tooltip: {
enabled: true,
mode: 'nearest',
intersect: false,
axis: 'x',
callbacks: {
// 自定义tooltip的标题
title: function(contexts) {
// 假设所有数据点共享相同的x轴标签
let title = contexts[0].label;
return title || '';
},
// 自定义tooltip的内容
label: function(context) {
// 这里可以访问到所有的数据点
let label = context.dataset.label || '';

if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y;
}
return label;
},
},
},
},
}}
data={{
labels: res.find(_ => _?.downloads)!.downloads?.map((_) => _.day),
datasets: res.filter(_ => _?.downloads).map((_, index) => {
return {
fill: false,
label: pkgNameList[res.indexOf(_)],
data: _!.downloads.map((_) => _.downloads),
borderColor: COLOR_LIST[index],
// 会影响到 label 展示,虽然 fill false 也一并添加
backgroundColor: COLOR_LIST[index],
}
})
}}
/>
);
}
23 changes: 23 additions & 0 deletions src/hooks/useRecentDownloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import dayjs from 'dayjs';
import useSwr from 'swr';

const DEFAULT_RANGE = 7;
const INIT_YEAR = 2020;

type DownloadRes = {
downloads: { day: string; downloads: number; }[];
Expand All @@ -20,6 +21,19 @@ function getUrl(pkgName: string, range: number) {
return `${REGISTRY}/downloads/range/${lastWeekStr}:${todayStr}/${pkgName}`;
};

function getTotalUrl(pkgName: string) {
const today = dayjs();
const todayStr = today.format('YYYY-MM-DD');
const years = today.year() - INIT_YEAR + 1;
return new Array(years).fill(0).map((_, index) => {
const year = INIT_YEAR + index;
if (year === today.year()) {
return `${REGISTRY}/downloads/range/${year}-01-01:${todayStr}/${pkgName}`;
}
return `${REGISTRY}/downloads/range/${INIT_YEAR + index}-01-01:${INIT_YEAR + index}-12-31/${pkgName}`;
});
};

function normalizeRes(res: DownloadRes, version: string, range: number): DownloadRes {
// 根据 range,获取最近 range 天的数据
const downloads = res.downloads.slice(-range);
Expand Down Expand Up @@ -47,3 +61,12 @@ export const useRecentDownloads = (pkgName: string, version: string, range: numb
.then(res => normalizeRes(res, version, range));
});
};

export const useTotalDownloads = (pkgName: string) => {
return useSwr<DownloadRes>(pkgName ? `total_downloads: ${pkgName}` : null, async () => {
const res = await Promise.all(getTotalUrl(pkgName).map((url) => fetch(url).then((res) => res.json())));
return { downloads: res.reduce((acc, cur) => acc.concat(cur.downloads), []), versions: {} };
}, {
refreshInterval: 0,
});
};
2 changes: 2 additions & 0 deletions src/pages/package/[...slug]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ThemeProvider as _ThemeProvider } from 'antd-style';
import PageHome from '@/slugs/home';
import PageFiles from '@/slugs/files';
import PageVersions from '@/slugs/versions';
import PageTrends from '@/slugs/trends';
import PageDeps from '@/slugs/deps';
import 'antd/dist/reset.css';
import CustomTabs from '@/components/CustomTabs';
Expand Down Expand Up @@ -65,6 +66,7 @@ const PageMap: Record<string, (params: PageProps) => JSX.Element> = {
deps: PageDeps,
files: PageFiles,
versions: PageVersions,
trends: PageTrends,
} as const;

// 由于路由不支持 @scope 可选参数
Expand Down
60 changes: 60 additions & 0 deletions src/slugs/trends/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';
import SizeContainer from '@/components/SizeContainer';
import { Card, Select, Typography } from 'antd';
import React, { useState } from 'react';
import { PageProps } from '@/pages/package/[...slug]';
import { TotalDownloads } from '@/components/RecentDownloads';
import { useCachedSearch } from '@/hooks/useSearch';
import { DownOutlined } from '@ant-design/icons';

const MAX_COUNT = 5;

export default function Trends({ manifest: pkg, additionalInfo: needSync, version }: PageProps) {
const [search, setSearch] = useState('');
const [pkgs, setPkgs] = useState([pkg.name]);
const { data: searchResult, isLoading } = useCachedSearch({
keyword: search,
page: 1,
});

const suffix = (
<>
<span>
{pkgs.length} / {MAX_COUNT}
</span>
<DownOutlined />
</>
);

return (
<>
<SizeContainer maxWidth={1072}>
<Select
mode="multiple"
maxCount={MAX_COUNT}
value={pkgs}
style={{ width: '100%' }}
onSearch={setSearch}
suffixIcon={suffix}
placeholder="Please select"
defaultValue={pkgs}
onChange={setPkgs}
options={searchResult?.objects.map((object) => ({
label: (
<>
<Typography.Text>
{object.package.name}
</Typography.Text>
<br />
</>
),
value: object.package.name,
}))}
/>
<Card style={{ marginTop: 24 }}>
<TotalDownloads pkgNameList={pkgs}/>
</Card>
</SizeContainer>
</>
);
}

0 comments on commit cd8ccb7

Please sign in to comment.