Skip to content

Commit c4ff5b6

Browse files
authored
feat(view): 네트워크 그래프 UI 구현 (#885)
1 parent 9c24c01 commit c4ff5b6

7 files changed

Lines changed: 332 additions & 25 deletions

File tree

packages/view/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RefreshButton } from "components/RefreshButton";
1212
import type { IDESentEvents } from "types/IDESentEvents";
1313
import { useBranchStore, useDataStore, useGithubInfo, useLoadingStore, useThemeStore } from "store";
1414
import { THEME_INFO } from "components/ThemeSelector/ThemeSelector.const";
15+
import { NetworkGraph } from "components/NetworkGraph";
1516

1617
const App = () => {
1718
const initRef = useRef<boolean>(false);
@@ -77,6 +78,7 @@ const App = () => {
7778
<p>Make at least one commit to proceed.</p>
7879
</div>
7980
)}
81+
<NetworkGraph />
8082
</div>
8183
</>
8284
);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const CHARGE_STRENGTH = -300;
2+
export const LINK_STRENGTH = 0.3;
3+
export const DISTANCE = 100;
4+
5+
export const DIMENSIONS = {
6+
width: 1200,
7+
height: 800,
8+
margin: {
9+
top: 40,
10+
right: 40,
11+
bottom: 40,
12+
left: 40,
13+
},
14+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.network-graph {
2+
&__graph {
3+
background: transparent;
4+
cursor: grab;
5+
}
6+
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { useEffect, useRef, useMemo, useState } from "react";
2+
import * as d3 from "d3";
3+
import { useShallow } from "zustand/react/shallow";
4+
5+
import { useDataStore } from "store";
6+
7+
import { processNetworkGraphData } from "./NetworkGraph.util";
8+
import type { NetworkNode, NetworkLink } from "./NetworkGraph.type";
9+
import { CHARGE_STRENGTH, DIMENSIONS, DISTANCE, LINK_STRENGTH } from "./NetworkGraph.const";
10+
import "./NetworkGraph.scss";
11+
12+
const NetworkGraph = () => {
13+
const svgRef = useRef<SVGSVGElement>(null);
14+
const simulationRef = useRef<d3.Simulation<NetworkNode, undefined> | null>(null);
15+
const [data] = useDataStore(useShallow((state) => [state.data]));
16+
const [nodeType, setNodeType] = useState<"contributor" | "file" | "both">("both");
17+
18+
const networkData = useMemo(() => {
19+
return processNetworkGraphData(data, nodeType);
20+
}, [data, nodeType]);
21+
22+
const dragstarted = (event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
23+
if (!event.active) simulationRef.current?.alphaTarget(0.3).restart();
24+
const node = d;
25+
node.fx = node.x;
26+
node.fy = node.y;
27+
};
28+
29+
const dragged = (event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
30+
const node = d;
31+
node.fx = event.x;
32+
node.fy = event.y;
33+
};
34+
35+
const dragended = (event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
36+
if (!event.active) simulationRef.current?.alphaTarget(0);
37+
38+
const node = d;
39+
node.fx = null;
40+
node.fy = null;
41+
};
42+
43+
useEffect(() => {
44+
if (!svgRef.current || !networkData) return;
45+
46+
const svg = d3.select(svgRef.current);
47+
svg.selectAll("*").remove();
48+
49+
const chartWidth = DIMENSIONS.width - DIMENSIONS.margin.left - DIMENSIONS.margin.right;
50+
const chartHeight = DIMENSIONS.height - DIMENSIONS.margin.top - DIMENSIONS.margin.bottom;
51+
52+
const g = svg.append("g").attr("transform", `translate(${DIMENSIONS.margin.left},${DIMENSIONS.margin.top})`);
53+
54+
const defs = svg.append("defs");
55+
56+
const nodeGradient = defs
57+
.append("radialGradient")
58+
.attr("id", "node-gradient")
59+
.attr("cx", "30%")
60+
.attr("cy", "30%")
61+
.attr("r", "70%");
62+
63+
nodeGradient.append("stop").attr("offset", "0%").attr("stop-color", "#4a9eff").attr("stop-opacity", 0.8);
64+
nodeGradient.append("stop").attr("offset", "100%").attr("stop-color", "#1a1a2e").attr("stop-opacity", 0.6);
65+
66+
const linkGradient = defs
67+
.append("linearGradient")
68+
.attr("id", "link-gradient")
69+
.attr("gradientUnits", "userSpaceOnUse");
70+
71+
linkGradient.append("stop").attr("offset", "0%").attr("stop-color", "#4a9eff").attr("stop-opacity", 0.3);
72+
linkGradient.append("stop").attr("offset", "100%").attr("stop-color", "#61dafb").attr("stop-opacity", 0.1);
73+
74+
const { nodes, links, colorScale } = networkData;
75+
76+
const simulation = d3
77+
.forceSimulation<NetworkNode>(nodes)
78+
.force(
79+
"link",
80+
d3
81+
.forceLink<NetworkNode, NetworkLink>(links)
82+
.id((d: NetworkNode) => d.id)
83+
.distance(DISTANCE)
84+
.strength(LINK_STRENGTH)
85+
)
86+
.force("charge", d3.forceManyBody().strength(CHARGE_STRENGTH))
87+
.force("center", d3.forceCenter(chartWidth / 2, chartHeight / 2))
88+
.force(
89+
"collision",
90+
d3.forceCollide<NetworkNode>().radius((d: NetworkNode) => d.radius + 5)
91+
);
92+
93+
simulationRef.current = simulation;
94+
95+
const link = g
96+
.append("g")
97+
.attr("class", "links")
98+
.selectAll<SVGLineElement, NetworkLink>("line")
99+
.data(links)
100+
.enter()
101+
.append("line")
102+
.attr("stroke", "url(#link-gradient)")
103+
.attr("stroke-width", (d: NetworkLink) => Math.max(1, d.weight * 3))
104+
.style("opacity", 0.6)
105+
.style("cursor", "pointer");
106+
107+
const node = g
108+
.append("g")
109+
.attr("class", "nodes")
110+
.selectAll<SVGGElement, NetworkNode>(".node")
111+
.data(nodes)
112+
.enter()
113+
.append("g")
114+
.attr("class", "node")
115+
.style("cursor", "pointer")
116+
.call(
117+
d3
118+
.drag<SVGGElement, NetworkNode, NetworkNode>()
119+
.on("start", dragstarted)
120+
.on("drag", dragged)
121+
.on("end", dragended)
122+
);
123+
124+
node
125+
.append("circle")
126+
.attr("r", (d) => d.radius)
127+
.attr("fill", (d) => colorScale(d.type))
128+
.attr("stroke", "#ffffff")
129+
.attr("stroke-width", 2)
130+
.style("filter", "drop-shadow(0 0 8px rgba(74, 158, 255, 0.3))");
131+
132+
node
133+
.append("text")
134+
.attr("dy", ".35em")
135+
.attr("text-anchor", "middle")
136+
.style("font-size", "10px")
137+
.style("font-weight", "bold")
138+
.style("fill", "#ffffff")
139+
.style("text-shadow", "1px 1px 2px rgba(0,0,0,0.8)")
140+
.text((d) => {
141+
const text = d.id.length > 12 ? `${d.id.substring(0, 10)}...` : d.id;
142+
return text;
143+
});
144+
145+
node
146+
.on("mouseover", function (event, d) {
147+
d3.selectAll(".network-tooltip").remove();
148+
d3.select(this)
149+
.select("circle")
150+
.attr("r", d.radius * 1.3)
151+
.style("filter", "drop-shadow(0 0 15px rgba(74, 158, 255, 0.6))");
152+
153+
g.selectAll("line").style("opacity", 0.2);
154+
(g.selectAll("line") as d3.Selection<SVGLineElement, NetworkLink, SVGGElement, unknown>)
155+
.filter((l: NetworkLink) => l.source.id === d.id || l.target.id === d.id)
156+
.style("opacity", 0.8)
157+
.attr("stroke-width", (l: NetworkLink) => Math.max(2, l.weight * 4));
158+
159+
const tooltip = d3
160+
.select("body")
161+
.append("div")
162+
.attr("class", "network-tooltip")
163+
.style("position", "absolute")
164+
.style("background", "rgba(0, 0, 0, 0.9)")
165+
.style("color", "white")
166+
.style("padding", "12px")
167+
.style("border-radius", "8px")
168+
.style("font-size", "12px")
169+
.style("pointer-events", "none")
170+
.style("z-index", "1000")
171+
.style("border", `3px solid ${colorScale(d.type)}`)
172+
.style("box-shadow", `0 0 20px ${colorScale(d.type)}`);
173+
174+
const typeText = d.type === "contributor" ? "👤 Contributors" : "📁 Files";
175+
176+
tooltip
177+
.html(
178+
`<div style="font-weight: bold; margin-bottom: 4px;">${typeText}</div>
179+
<div> ${d.id}</div>
180+
`
181+
)
182+
.style("left", `${event.pageX + 15}px`)
183+
.style("top", `${event.pageY - 10}px`);
184+
})
185+
.on("mouseout", function (_, d) {
186+
d3.select(this)
187+
.select("circle")
188+
.attr("r", d.radius)
189+
.style("filter", "drop-shadow(0 0 8px rgba(74, 158, 255, 0.3))");
190+
191+
(g.selectAll("line") as d3.Selection<SVGLineElement, NetworkLink, SVGGElement, unknown>)
192+
.style("opacity", 0.6)
193+
.attr("stroke-width", (l: NetworkLink) => Math.max(1, l.weight * 3));
194+
195+
d3.selectAll(".network-tooltip").remove();
196+
});
197+
198+
simulation.on("tick", () => {
199+
link
200+
.attr("x1", (d: NetworkLink) => d.source.x ?? 0)
201+
.attr("y1", (d: NetworkLink) => d.source.y ?? 0)
202+
.attr("x2", (d: NetworkLink) => d.target.x ?? 0)
203+
.attr("y2", (d: NetworkLink) => d.target.y ?? 0);
204+
205+
node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`);
206+
});
207+
208+
const legendGroup = svg
209+
.append("g")
210+
.attr("class", "legend")
211+
.attr("transform", `translate(${DIMENSIONS.width - 250}, 50)`);
212+
213+
legendGroup
214+
.append("rect")
215+
.attr("width", 150)
216+
.attr("height", 100)
217+
.attr("rx", 12)
218+
.style("fill", "rgba(15, 23, 42, 0.95)")
219+
.style("stroke", "rgba(59, 130, 246, 0.4)")
220+
.style("stroke-width", 1.5)
221+
.style("backdrop-filter", "blur(10px)")
222+
.style("box-shadow", "0 8px 32px rgba(0, 0, 0, 0.3)");
223+
224+
const nodeTypeOptions = [
225+
{ value: "both", label: "All Nodes" },
226+
{ value: "contributor", label: "Contributors" },
227+
{ value: "file", label: "Files" },
228+
];
229+
230+
nodeTypeOptions.forEach((option, i) => {
231+
const optionGroup = legendGroup.append("g").attr("transform", `translate(20, ${25 + i * 22})`);
232+
233+
optionGroup
234+
.append("circle")
235+
.attr("r", 6)
236+
.attr("cx", 0)
237+
.attr("cy", 0)
238+
.style("fill", nodeType === option.value ? "#3b82f6" : "transparent")
239+
.style("stroke", "#3b82f6")
240+
.style("stroke-width", 2);
241+
242+
if (nodeType === option.value) {
243+
optionGroup.append("circle").attr("r", 3).attr("cx", 0).attr("cy", 0).style("fill", "#ffffff");
244+
}
245+
246+
optionGroup
247+
.append("text")
248+
.attr("x", 18)
249+
.attr("y", 4)
250+
.style("font-size", "12px")
251+
.style("fill", "#e2e8f0")
252+
.style("font-weight", nodeType === option.value ? "600" : "400")
253+
.text(option.label);
254+
255+
optionGroup.style("cursor", "pointer").on("click", () => {
256+
setNodeType(option.value as "contributor" | "file" | "both");
257+
});
258+
});
259+
260+
return () => {
261+
if (simulationRef.current) {
262+
simulationRef.current.stop();
263+
}
264+
};
265+
}, [networkData, nodeType]);
266+
267+
return (
268+
<div className="contributor-timeline">
269+
{networkData && (
270+
<svg
271+
ref={svgRef}
272+
width={DIMENSIONS.width}
273+
height={DIMENSIONS.height}
274+
className="network-graph__graph"
275+
/>
276+
)}
277+
</div>
278+
);
279+
};
280+
281+
export default NetworkGraph;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type * as d3 from "d3";
2+
3+
export interface NetworkNode extends d3.SimulationNodeDatum {
4+
id: string;
5+
type: "contributor" | "file";
6+
radius: number;
7+
weight: number;
8+
connectionCount: number;
9+
}
10+
11+
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
12+
source: NetworkNode;
13+
target: NetworkNode;
14+
weight: number;
15+
sourceType: "contributor" | "file";
16+
targetType: "contributor" | "file";
17+
}
18+
19+
export interface NetworkGraphData {
20+
nodes: NetworkNode[];
21+
links: NetworkLink[];
22+
colorScale: d3.ScaleOrdinal<string, string>;
23+
}

0 commit comments

Comments
 (0)