|
| 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; |
0 commit comments