Skip to content

Commit 7c87e24

Browse files
authored
Merge pull request #888 from githru/feat/storyline-chart
feat(view): 스토리라인 차트 UI 구현
2 parents c4ff5b6 + d88c5de commit 7c87e24

11 files changed

Lines changed: 889 additions & 2 deletions

packages/view/src/App.scss

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,46 @@ body {
3535
margin: 2.5rem 0 0 0;
3636
}
3737
}
38+
39+
.folder-activity-flow-button {
40+
padding: 0.5rem 1rem;
41+
background: #007bff;
42+
color: white;
43+
border: none;
44+
border-radius: 0.25rem;
45+
cursor: pointer;
46+
font-size: 0.875rem;
47+
}
48+
49+
.folder-activity-flow-modal {
50+
position: fixed;
51+
top: 0;
52+
left: 0;
53+
width: 100%;
54+
height: 100%;
55+
background-color: rgba(0, 0, 0, 0.5);
56+
display: flex;
57+
justify-content: center;
58+
align-items: center;
59+
z-index: 1000;
60+
}
61+
62+
.folder-activity-flow-modal-content {
63+
background-color: white;
64+
padding: 1.25rem;
65+
border-radius: 0.5rem;
66+
width: 90%;
67+
height: 80%;
68+
overflow: auto;
69+
position: relative;
70+
}
71+
72+
.folder-activity-flow-modal-close {
73+
position: absolute;
74+
top: 0.625rem;
75+
right: 0.625rem;
76+
background: none;
77+
border: none;
78+
font-size: 1.25rem;
79+
cursor: pointer;
80+
}

packages/view/src/App.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import "reflect-metadata";
22
import { container } from "tsyringe";
3-
import { useEffect, useRef } from "react";
3+
import { useEffect, useRef, useState } from "react";
44
import BounceLoader from "react-spinners/BounceLoader";
55

66
import MonoLogo from "assets/monoLogo.svg";
7-
import { BranchSelector, Statistics, TemporalFilter, ThemeSelector, VerticalClusterList } from "components";
7+
import { BranchSelector, Statistics, TemporalFilter, ThemeSelector, VerticalClusterList, FolderActivityFlow } from "components";
88
import "./App.scss";
99
import type IDEPort from "ide/IDEPort";
1010
import { useAnalayzedData } from "hooks";
@@ -16,6 +16,7 @@ import { NetworkGraph } from "components/NetworkGraph";
1616

1717
const App = () => {
1818
const initRef = useRef<boolean>(false);
19+
const [showFolderActivityFlowModal, setShowFolderActivityFlowModal] = useState(false);
1920
const { handleChangeAnalyzedData } = useAnalayzedData();
2021
const filteredData = useDataStore((state) => state.filteredData);
2122
const { handleChangeBranchList } = useBranchStore();
@@ -24,6 +25,14 @@ const App = () => {
2425
const { theme } = useThemeStore();
2526
const ideAdapter = container.resolve<IDEPort>("IDEAdapter");
2627

28+
const handleOpenFolderActivityFlowModal = () => {
29+
setShowFolderActivityFlowModal(true);
30+
};
31+
32+
const handleCloseFolderActivityFlowModal = () => {
33+
setShowFolderActivityFlowModal(false);
34+
};
35+
2736
useEffect(() => {
2837
if (initRef.current === false) {
2938
const callbacks: IDESentEvents = {
@@ -61,6 +70,12 @@ const App = () => {
6170
<ThemeSelector />
6271
<BranchSelector />
6372
<RefreshButton />
73+
<button
74+
className="folder-activity-flow-button"
75+
onClick={handleOpenFolderActivityFlowModal}
76+
>
77+
Folder Activity Flow
78+
</button>
6479
</div>
6580
<div className="top-container">
6681
<TemporalFilter />
@@ -80,6 +95,27 @@ const App = () => {
8095
)}
8196
<NetworkGraph />
8297
</div>
98+
99+
{/* Folder Activity Flow Modal */}
100+
{showFolderActivityFlowModal && (
101+
<div
102+
className="folder-activity-flow-modal"
103+
onClick={handleCloseFolderActivityFlowModal}
104+
>
105+
<div
106+
className="folder-activity-flow-modal-content"
107+
onClick={(e) => e.stopPropagation()}
108+
>
109+
<button
110+
className="folder-activity-flow-modal-close"
111+
onClick={handleCloseFolderActivityFlowModal}
112+
>
113+
×
114+
</button>
115+
<FolderActivityFlow />
116+
</div>
117+
</div>
118+
)}
83119
</>
84120
);
85121
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ClusterNode } from "types";
2+
3+
export interface FolderActivity {
4+
folderPath: string;
5+
totalChanges: number;
6+
insertions: number;
7+
deletions: number;
8+
commitCount: number;
9+
}
10+
11+
export interface CommitData {
12+
id: string;
13+
authorDate: string;
14+
commitDate: string;
15+
diffStatistics: {
16+
insertions: number;
17+
deletions: number;
18+
files: { [filePath: string]: { insertions: number; deletions: number } };
19+
};
20+
}
21+
22+
export function extractFolderFromPath(filePath: string, depth: number = 1): string {
23+
const parts = filePath.split('/');
24+
if (parts.length === 1) return '.'; // 루트 레벨 파일
25+
26+
const folderParts = parts.slice(0, Math.min(depth, parts.length - 1));
27+
return folderParts.length > 0 ? folderParts.join('/') : '.';
28+
}
29+
30+
export function analyzeFolderActivity(clusterNodeList: ClusterNode[], folderDepth: number = 1): FolderActivity[] {
31+
const folderStats = new Map<string, FolderActivity>();
32+
33+
// 클러스터에서 모든 커밋 추출
34+
const allCommits: CommitData[] = [];
35+
clusterNodeList.forEach(cluster => {
36+
cluster.commitNodeList.forEach(commitNode => {
37+
allCommits.push(commitNode.commit);
38+
});
39+
});
40+
41+
// 각 커밋의 파일 변경사항 분석
42+
allCommits.forEach(commit => {
43+
Object.entries(commit.diffStatistics.files).forEach(([filePath, stats]) => {
44+
const folderPath = extractFolderFromPath(filePath, folderDepth);
45+
46+
if (!folderStats.has(folderPath)) {
47+
folderStats.set(folderPath, {
48+
folderPath,
49+
totalChanges: 0,
50+
insertions: 0,
51+
deletions: 0,
52+
commitCount: 0
53+
});
54+
}
55+
56+
const folderActivity = folderStats.get(folderPath)!;
57+
folderActivity.insertions += stats.insertions;
58+
folderActivity.deletions += stats.deletions;
59+
folderActivity.totalChanges += stats.insertions + stats.deletions;
60+
});
61+
});
62+
63+
// 폴더별 고유 커밋 수 계산
64+
allCommits.forEach(commit => {
65+
const foldersInCommit = new Set<string>();
66+
Object.keys(commit.diffStatistics.files).forEach(filePath => {
67+
const folderPath = extractFolderFromPath(filePath, folderDepth);
68+
foldersInCommit.add(folderPath);
69+
});
70+
71+
foldersInCommit.forEach(folderPath => {
72+
if (folderStats.has(folderPath)) {
73+
folderStats.get(folderPath)!.commitCount++;
74+
}
75+
});
76+
});
77+
78+
// 배열로 변환하고 총 활동량 기준 정렬
79+
return Array.from(folderStats.values())
80+
.sort((a, b) => b.totalChanges - a.totalChanges);
81+
}
82+
83+
export function getTopFolders(clusterNodeList: ClusterNode[], count: number = 5, folderDepth: number = 1): FolderActivity[] {
84+
const allActivities = analyzeFolderActivity(clusterNodeList, folderDepth);
85+
return allActivities.slice(0, count);
86+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const DIMENSIONS = {
2+
width: 800,
3+
height: 400,
4+
margin: { top: 40, right: 120, bottom: 60, left: 20 }
5+
};
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
.folder-activity-flow {
2+
padding: 1rem;
3+
margin-bottom: 2rem;
4+
background: #ffffff;
5+
border-radius: 8px;
6+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
7+
8+
&__title {
9+
margin: 0 0 0.5rem 0;
10+
font-size: 1.2rem;
11+
font-weight: 600;
12+
color: #212529;
13+
}
14+
15+
&__subtitle {
16+
margin: 0 0 0.5rem 0;
17+
font-size: 0.875rem;
18+
color: #6c757d;
19+
}
20+
21+
&__breadcrumb {
22+
margin: 0 0 1rem 0;
23+
padding: 0.5rem;
24+
background: #f8f9fa;
25+
border-radius: 4px;
26+
font-size: 0.875rem;
27+
display: flex;
28+
align-items: center;
29+
gap: 0.5rem;
30+
31+
.separator {
32+
color: #6c757d;
33+
}
34+
35+
.clickable {
36+
color: #007bff;
37+
cursor: pointer;
38+
text-decoration: underline;
39+
40+
&:hover {
41+
color: #0056b3;
42+
}
43+
}
44+
45+
.current {
46+
color: #212529;
47+
font-weight: 600;
48+
}
49+
}
50+
51+
&__back-btn {
52+
margin-left: auto;
53+
padding: 0.25rem 0.5rem;
54+
background: #007bff;
55+
color: white;
56+
border: none;
57+
border-radius: 4px;
58+
font-size: 0.75rem;
59+
cursor: pointer;
60+
61+
&:hover {
62+
background: #0056b3;
63+
}
64+
}
65+
66+
&__chart {
67+
display: block;
68+
margin: 0 auto;
69+
cursor: grab;
70+
71+
&:active {
72+
cursor: grabbing;
73+
}
74+
}
75+
76+
&__tooltip {
77+
position: absolute;
78+
display: none;
79+
padding: 0.5rem;
80+
background: rgba(0, 0, 0, 0.9);
81+
color: white;
82+
border-radius: 4px;
83+
font-size: 0.75rem;
84+
pointer-events: none;
85+
z-index: 1000;
86+
87+
.contributor-activity-tooltip {
88+
p {
89+
margin: 0.25rem 0;
90+
91+
&:first-child {
92+
margin-top: 0;
93+
font-weight: 600;
94+
}
95+
96+
&:last-child {
97+
margin-bottom: 0;
98+
}
99+
}
100+
}
101+
}
102+
103+
.lane-background {
104+
transition: fill 0.2s ease;
105+
106+
&:hover {
107+
fill: #e9ecef;
108+
}
109+
}
110+
111+
.folder-label {
112+
font-weight: 500;
113+
114+
&.clickable {
115+
cursor: pointer;
116+
transition: fill 0.2s ease;
117+
118+
&:hover {
119+
fill: #007bff !important;
120+
}
121+
}
122+
}
123+
124+
.activity-dot {
125+
cursor: pointer;
126+
transition: all 0.2s ease;
127+
128+
&:hover {
129+
stroke-width: 2;
130+
r: 8;
131+
}
132+
}
133+
134+
.flow-line {
135+
pointer-events: none;
136+
}
137+
138+
.x-axis {
139+
.domain,
140+
.tick line {
141+
stroke: #dee2e6;
142+
}
143+
144+
.tick text {
145+
fill: #6c757d;
146+
font-size: 11px;
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)