Skip to content

Commit 7eef61d

Browse files
committed
feat: support package graph
1 parent 8f24579 commit 7eef61d

5 files changed

Lines changed: 311 additions & 3 deletions

File tree

packages/components/src/pages/PackageGraph/components/GraphView.tsx

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import React, { useCallback, useMemo, useRef, useState } from 'react';
22
import EChartsReactCore from 'echarts-for-react/esm/core';
33
import * as echarts from 'echarts/core';
4-
import { GraphChart, type GraphSeriesOption } from 'echarts/charts';
4+
import {
5+
GraphChart,
6+
TreemapChart,
7+
type GraphSeriesOption,
8+
type TreemapSeriesOption,
9+
} from 'echarts/charts';
510
import {
611
TooltipComponent,
712
LegendComponent,
@@ -14,17 +19,39 @@ import { Alert, Empty, Input, Space, Switch, Typography } from 'antd';
1419
import { SDK } from '@rsdoctor/types';
1520
import { formatSize } from 'src/utils';
1621
import { DetailPanel, PackageNodeInfo } from './DetailPanel';
22+
import { buildPackageSizeTreemapData } from './packageSizeTreemap';
1723

18-
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer]);
24+
echarts.use([
25+
GraphChart,
26+
TreemapChart,
27+
TooltipComponent,
28+
LegendComponent,
29+
CanvasRenderer,
30+
]);
1931

2032
type GraphOption = ComposeOption<
2133
GraphSeriesOption | TooltipComponentOption | LegendComponentOption
2234
>;
2335

36+
type TreemapOption = ComposeOption<
37+
TreemapSeriesOption | TooltipComponentOption | LegendComponentOption
38+
>;
39+
2440
// Node colors
2541
const COLOR_NORMAL = '#4dabf7'; // blue – regular package
2642
const COLOR_DUPLICATE = '#ff6b6b'; // red – duplicate package
2743
const COLOR_SOURCE = '#51cf66'; // green – user source code (virtual root)
44+
const TREEMAP_COLORS = [
45+
'#4dabf7',
46+
'#51cf66',
47+
'#ffd43b',
48+
'#ff922b',
49+
'#845ef7',
50+
'#20c997',
51+
'#f06595',
52+
'#15aabf',
53+
'#adb5bd',
54+
];
2855

2956
// Min/max node symbol sizes
3057
const MIN_SIZE = 16;
@@ -90,6 +117,14 @@ export const GraphView: React.FC<GraphViewProps> = ({
90117
const sizes = packages.map((p) => p.size.parsedSize);
91118
const minSize = Math.min(...sizes);
92119
const maxSize = Math.max(...sizes);
120+
const treemapData = useMemo(
121+
() => buildPackageSizeTreemapData(visiblePackages),
122+
[visiblePackages],
123+
);
124+
const visiblePackagesSize = useMemo(
125+
() => treemapData.reduce((total, item) => total + item.value, 0),
126+
[treemapData],
127+
);
93128

94129
const option = useMemo<GraphOption>(() => {
95130
const nodes: GraphSeriesOption['data'] = visiblePackages.map((pkg) => {
@@ -205,6 +240,78 @@ export const GraphView: React.FC<GraphViewProps> = ({
205240
packages,
206241
]);
207242

243+
const treemapOption = useMemo<TreemapOption>(
244+
() => ({
245+
color: TREEMAP_COLORS,
246+
tooltip: {
247+
trigger: 'item',
248+
confine: true,
249+
formatter: (params: any) => {
250+
const data = params.data;
251+
if (!data) return '';
252+
return [
253+
`<b>${echarts.format.encodeHTML(data.packageName)}</b>`,
254+
`Version: ${echarts.format.encodeHTML(data.version)}`,
255+
`Parsed: ${formatSize(data.value)}`,
256+
`Share: ${data.percent.toFixed(2)}%`,
257+
`Gzip: ${formatSize(data.gzipSize)}`,
258+
`Source: ${formatSize(data.sourceSize)}`,
259+
].join('<br/>');
260+
},
261+
},
262+
series: [
263+
{
264+
type: 'treemap',
265+
data: treemapData,
266+
roam: true,
267+
nodeClick: false,
268+
breadcrumb: { show: false },
269+
left: 0,
270+
right: 0,
271+
top: 0,
272+
bottom: 0,
273+
width: '100%',
274+
height: '100%',
275+
itemStyle: {
276+
borderColor: '#fff',
277+
borderWidth: 2,
278+
gapWidth: 2,
279+
},
280+
label: {
281+
show: true,
282+
color: '#fff',
283+
fontSize: 11,
284+
overflow: 'truncate',
285+
formatter: (params: any) => {
286+
const data = params.data;
287+
if (!data) return params.name;
288+
return `${data.packageName}\n${data.percent.toFixed(1)}%`;
289+
},
290+
},
291+
upperLabel: {
292+
show: false,
293+
},
294+
emphasis: {
295+
itemStyle: {
296+
borderColor: '#1c7ed6',
297+
borderWidth: 3,
298+
},
299+
},
300+
levels: [
301+
{
302+
itemStyle: {
303+
borderColor: '#fff',
304+
borderWidth: 2,
305+
gapWidth: 2,
306+
},
307+
},
308+
],
309+
},
310+
],
311+
}),
312+
[treemapData],
313+
);
314+
208315
const onChartClick = useCallback(
209316
(params: any) => {
210317
if (params.dataType !== 'node') return;
@@ -216,6 +323,23 @@ export const GraphView: React.FC<GraphViewProps> = ({
216323
[packages, dependencies],
217324
);
218325

326+
const openPackageDetail = useCallback(
327+
(id: number) => {
328+
const pkg = packages.find((item) => item.id === id);
329+
if (!pkg) return;
330+
setSelectedPkg({ pkg, dependencies, allPackages: packages });
331+
setDrawerOpen(true);
332+
},
333+
[packages, dependencies],
334+
);
335+
336+
const onTreemapClick = useCallback(
337+
(params: any) => {
338+
openPackageDetail(Number(params.data?.id));
339+
},
340+
[openPackageDetail],
341+
);
342+
219343
if (packages.length === 0) {
220344
return <Empty description="No package data available" />;
221345
}
@@ -278,6 +402,35 @@ export const GraphView: React.FC<GraphViewProps> = ({
278402
notMerge
279403
/>
280404

405+
<div style={{ marginTop: 20 }}>
406+
<Space
407+
align="baseline"
408+
style={{
409+
width: '100%',
410+
justifyContent: 'space-between',
411+
marginBottom: 8,
412+
}}
413+
>
414+
<Typography.Title level={5} style={{ margin: 0 }}>
415+
Package Size Treemap
416+
</Typography.Title>
417+
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
418+
Parsed size total: {formatSize(visiblePackagesSize)}
419+
</Typography.Text>
420+
</Space>
421+
{treemapData.length > 0 ? (
422+
<EChartsReactCore
423+
echarts={echarts}
424+
option={treemapOption}
425+
style={{ height: 420, width: '100%' }}
426+
onEvents={{ click: onTreemapClick }}
427+
notMerge
428+
/>
429+
) : (
430+
<Empty description="No package size data available" />
431+
)}
432+
</div>
433+
281434
<DetailPanel
282435
info={selectedPkg}
283436
open={drawerOpen}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it } from '@rstest/core';
2+
import type { SDK } from '@rsdoctor/types';
3+
import { buildPackageSizeTreemapData } from './packageSizeTreemap';
4+
5+
function createPackage(
6+
partial: Pick<SDK.PackageData, 'id' | 'name' | 'version'> & {
7+
parsedSize: number;
8+
gzipSize?: number;
9+
sourceSize?: number;
10+
},
11+
): SDK.PackageData {
12+
return {
13+
id: partial.id,
14+
name: partial.name,
15+
version: partial.version,
16+
root: `/node_modules/${partial.name}`,
17+
modules: [],
18+
size: {
19+
parsedSize: partial.parsedSize,
20+
gzipSize: partial.gzipSize ?? 0,
21+
sourceSize: partial.sourceSize ?? 0,
22+
transformedSize: 0,
23+
},
24+
};
25+
}
26+
27+
describe('buildPackageSizeTreemapData', () => {
28+
it('sorts packages by parsed size and adds package size percentage', () => {
29+
const data = buildPackageSizeTreemapData([
30+
createPackage({
31+
id: 1,
32+
name: 'small',
33+
version: '1.0.0',
34+
parsedSize: 25,
35+
}),
36+
createPackage({
37+
id: 2,
38+
name: 'large',
39+
version: '1.0.0',
40+
parsedSize: 75,
41+
gzipSize: 30,
42+
}),
43+
]);
44+
45+
expect(data).toStrictEqual([
46+
{
47+
id: '2',
48+
name: 'large@1.0.0',
49+
packageName: 'large',
50+
version: '1.0.0',
51+
value: 75,
52+
percent: 75,
53+
gzipSize: 30,
54+
sourceSize: 0,
55+
},
56+
{
57+
id: '1',
58+
name: 'small@1.0.0',
59+
packageName: 'small',
60+
version: '1.0.0',
61+
value: 25,
62+
percent: 25,
63+
gzipSize: 0,
64+
sourceSize: 0,
65+
},
66+
]);
67+
});
68+
69+
it('filters packages without parsed size', () => {
70+
const data = buildPackageSizeTreemapData([
71+
createPackage({
72+
id: 1,
73+
name: 'empty',
74+
version: '1.0.0',
75+
parsedSize: 0,
76+
}),
77+
]);
78+
79+
expect(data).toStrictEqual([]);
80+
});
81+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { SDK } from '@rsdoctor/types';
2+
3+
export interface PackageSizeTreemapNode {
4+
id: string;
5+
name: string;
6+
packageName: string;
7+
version: string;
8+
value: number;
9+
percent: number;
10+
gzipSize: number;
11+
sourceSize: number;
12+
}
13+
14+
export function buildPackageSizeTreemapData(
15+
packages: SDK.PackageData[],
16+
): PackageSizeTreemapNode[] {
17+
const packagesWithSize = packages.filter((pkg) => pkg.size.parsedSize > 0);
18+
const totalSize = packagesWithSize.reduce(
19+
(total, pkg) => total + pkg.size.parsedSize,
20+
0,
21+
);
22+
23+
if (totalSize === 0) {
24+
return [];
25+
}
26+
27+
return packagesWithSize
28+
.map((pkg) => ({
29+
id: String(pkg.id),
30+
name: `${pkg.name}@${pkg.version}`,
31+
packageName: pkg.name,
32+
version: pkg.version,
33+
value: pkg.size.parsedSize,
34+
percent: (pkg.size.parsedSize / totalSize) * 100,
35+
gzipSize: pkg.size.gzipSize,
36+
sourceSize: pkg.size.sourceSize,
37+
}))
38+
.sort((a, b) => b.value - a.value);
39+
}

packages/utils/src/common/data/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,19 @@ export class APIDataLoader {
545545

546546
case SDK.ServerAPI.API.GetPackageDependency:
547547
return this.loader.loadData('packageGraph').then((packageGraph) => {
548-
return (packageGraph?.dependencies || []) as R;
548+
const dependencies = packageGraph?.dependencies || [];
549+
const dependencyKeys = new Set<string>();
550+
551+
return dependencies.filter((dep) => {
552+
const key = `${dep.package}:${dep.dependency}`;
553+
554+
if (dependencyKeys.has(key)) {
555+
return false;
556+
}
557+
558+
dependencyKeys.add(key);
559+
return true;
560+
}) as R;
549561
});
550562

551563
case SDK.ServerAPI.API.GetChunkGraphAI:

packages/utils/tests/common/data.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,27 @@ describe('test src/common/data/index.ts', () => {
5050
);
5151
});
5252
});
53+
54+
it('deduplicates package dependencies by package pair', async () => {
55+
const loader = new APIDataLoader({
56+
loadData: rs.fn().mockResolvedValue({
57+
packages: [],
58+
dependencies: [
59+
{ id: 1, package: 1, dependency: 2, refDependency: 101 },
60+
{ id: 2, package: 1, dependency: 2, refDependency: 102 },
61+
{ id: 3, package: 2, dependency: 1, refDependency: 103 },
62+
],
63+
}),
64+
loadManifest: rs.fn(),
65+
});
66+
67+
await expect(
68+
loader.loadAPI(SDK.ServerAPI.API.GetPackageDependency, {
69+
packageId: '',
70+
}),
71+
).resolves.toEqual([
72+
{ id: 1, package: 1, dependency: 2, refDependency: 101 },
73+
{ id: 3, package: 2, dependency: 1, refDependency: 103 },
74+
]);
75+
});
5376
});

0 commit comments

Comments
 (0)