"use client"; import { useEffect, useRef, useState } from "react"; import * as d3 from "d3"; interface GraphNode { id: string; name: string; type: string; file: string; x?: number; y?: number; fx?: number | null; fy?: number | null; } interface GraphEdge { source: string | GraphNode; target: string | GraphNode; type: string; } const TYPE_COLORS: Record = { Function: "#6366f1", Class: "#22c55e", Method: "#eab308", }; const EDGE_COLORS: Record = { CALLS: "#6366f1", IMPORTS: "#22c55e", EXTENDS: "#ef4444", HAS_METHOD: "#06b6d4", DEFINED_IN: "#1e1e2e", }; interface Props { onSelectNode: (uid: string) => void; } export function GraphViewer({ onSelectNode }: Props) { const svgRef = useRef(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch("/api/graph?limit=150") .then((r) => r.json()) .then((data) => { setLoading(false); if (!svgRef.current || !data.nodes?.length) return; renderGraph(svgRef.current, data.nodes, data.edges, onSelectNode); }) .catch(() => setLoading(false)); }, [onSelectNode]); if (loading) { return (
Loading graph...
); } return ; } function renderGraph( svg: SVGSVGElement, nodes: GraphNode[], edges: GraphEdge[], onSelect: (uid: string) => void ) { const width = svg.clientWidth; const height = svg.clientHeight; d3.select(svg).selectAll("*").remove(); const root = d3.select(svg); // Glow filter const defs = root.append("defs"); const filter = defs.append("filter").attr("id", "glow"); filter.append("feGaussianBlur").attr("stdDeviation", "3").attr("result", "blur"); filter.append("feMerge").selectAll("feMergeNode") .data(["blur", "SourceGraphic"]) .join("feMergeNode") .attr("in", (d) => d); const g = root.append("g"); root.call( d3.zoom() .scaleExtent([0.1, 4]) .on("zoom", (event) => g.attr("transform", event.transform)) ); const simulation = d3 .forceSimulation(nodes) .force( "link", d3.forceLink(edges) .id((d) => d.id) .distance(80) ) .force("charge", d3.forceManyBody().strength(-200)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collision", d3.forceCollide(25)); // Edges const link = g .append("g") .selectAll("line") .data(edges) .join("line") .attr("stroke", (d) => EDGE_COLORS[d.type] || "#1e1e2e") .attr("stroke-opacity", 0.3) .attr("stroke-width", 1); // Node groups const node = g .append("g") .selectAll("g") .data(nodes) .join("g") .style("cursor", "pointer") .on("click", (_, d) => onSelect(d.id)) .on("mouseenter", function () { d3.select(this).select("circle").attr("filter", "url(#glow)"); d3.select(this).select("text").attr("fill", "#e4e4ef"); }) .on("mouseleave", function () { d3.select(this).select("circle").attr("filter", null); d3.select(this).select("text").attr("fill", "#6b6b80"); }) .call( d3.drag() .on("start", (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on("drag", (event, d) => { d.fx = event.x; d.fy = event.y; }) .on("end", (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }) as any ); // Outer glow ring for Class nodes node .filter((d) => d.type === "Class") .append("circle") .attr("r", 14) .attr("fill", "none") .attr("stroke", (d) => TYPE_COLORS[d.type] || "#666") .attr("stroke-opacity", 0.15) .attr("stroke-width", 2); // Node circles node .append("circle") .attr("r", (d) => (d.type === "Class" ? 8 : 5)) .attr("fill", (d) => TYPE_COLORS[d.type] || "#666"); // Node labels node .append("text") .text((d) => d.name) .attr("x", (d) => (d.type === "Class" ? 18 : 12)) .attr("y", 4) .attr("font-size", "10px") .attr("font-family", "Inter, sans-serif") .attr("fill", "#6b6b80"); simulation.on("tick", () => { link .attr("x1", (d: any) => d.source.x) .attr("y1", (d: any) => d.source.y) .attr("x2", (d: any) => d.target.x) .attr("y2", (d: any) => d.target.y); node.attr("transform", (d) => `translate(${d.x}, ${d.y})`); }); }