diff --git a/README.md b/README.md index 86ed5d1..7422503 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,41 @@ -# vite-react-template +# code-graph -## download template +使用 Graphology 分析代码依赖关系,生成关系图谱,并使用 SigmaJS 可视化展示。 + +使用3d图展示 + +## 特性 + +- 代码依赖关系分析 +- 关系图可视化 +- 交互式图谱探索 + +## 技术栈 + +- **Graphology** - 图分析库 +- **SigmaJS** - 图可视化 +- **React** + **Vite** +- **TanStack Router** - 路由 +- **TailwindCSS v4** - 样式 + +## 快速开始 ```bash -ev sync clone -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template +# 安装依赖 +bun install + +# 启动开发服务器 +bun run dev ``` -## clone auth update +## 项目结构 -```bash -ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json ``` - -## code-graph - -使用graphology分析代码依赖关系,生成关系。 - -使用sigmajs可视化关系图。 - +src/ +├── components/ui/ # UI 组件 +├── lib/ # 工具函数 +├── modules/ # 应用模块 +├── pages/ # 页面组件 +├── routes/ # 路由配置 +└── styles/ # 样式文件 +``` diff --git a/src/pages/code-graph/components/Code3DGraph.tsx b/src/pages/code-graph/components/Code3DGraph.tsx index eeb6119..ae5cf2d 100644 --- a/src/pages/code-graph/components/Code3DGraph.tsx +++ b/src/pages/code-graph/components/Code3DGraph.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import ForceGraph3D from '3d-force-graph'; import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph'; +import * as THREE from 'three'; import { FileProjectData } from '../modules/tree'; import { NodeSearchEntry } from '../modules/graph'; import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox'; @@ -189,19 +190,26 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) { const searchBoxRef = useRef(null); const [searchIndex, setSearchIndex] = useState([]); - const { setNodeInfo } = useCodeGraphStore( + const { setNodeInfo, selectedNodeId } = useCodeGraphStore( useShallow((s) => ({ setNodeInfo: s.setNodeInfo, + selectedNodeId: s.nodeInfoData?.fullPath ?? null, })), ); + // 用 ref 避免 nodeThreeObject 回调的陈旧闭包 + const selectedNodeIdRef = useRef(null); + useEffect(() => { + selectedNodeIdRef.current = selectedNodeId; + }, [selectedNodeId]); + // 节点跳转 const focusNode = useCallback((nodeKey: string) => { const graph = graphRef.current; if (!graph) return; const node = (graph.graphData().nodes as Graph3DNode[]).find((n) => n.id === nodeKey); if (!node) return; - const distance = 80; + const distance = 160; const distRatio = 1 + distance / Math.hypot((node.x ?? 0), (node.y ?? 0), (node.z ?? 0)); graph.cameraPosition( { x: (node.x ?? 0) * distRatio, y: (node.y ?? 0) * distRatio, z: (node.z ?? 0) * distRatio }, @@ -210,6 +218,22 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) { ); }, []); + // 将节点世界坐标投影为屏幕坐标 + const nodeToScreenPos = useCallback((node: Graph3DNode): { x: number; y: number } => { + const graph = graphRef.current; + if (!graph) return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + const camera = graph.camera(); + const renderer = (graph as any).renderer?.() as THREE.WebGLRenderer | undefined; + if (!camera || !renderer) return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + const vec = new THREE.Vector3(node.x ?? 0, node.y ?? 0, node.z ?? 0); + vec.project(camera); + const rect = renderer.domElement.getBoundingClientRect(); + return { + x: ((vec.x + 1) / 2) * rect.width + rect.left, + y: ((-vec.y + 1) / 2) * rect.height + rect.top, + }; + }, []); + // 初始化 & 数据更新 useEffect(() => { const el = containerRef.current; @@ -231,31 +255,65 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) { .nodeRelSize(1) .nodeVal((node) => { const n = node as Graph3DNode; - return n.nodeSize * n.nodeSize; + return (n.nodeSize ?? 3) * (n.nodeSize ?? 3); }) .nodeColor((node) => (node as Graph3DNode).color) .nodeLabel((node) => (node as Graph3DNode).label) + .nodeThreeObject(((node: Graph3DNode) => { + const n = node as Graph3DNode; + if (!n?.fullPath || n.fullPath !== selectedNodeIdRef.current) return null; + + const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32); + const material = new THREE.MeshStandardMaterial({ + color: n.color, + emissive: n.color, + emissiveIntensity: 1.5, + roughness: 0.3, + metalness: 0.5, + transparent: true, + opacity: 0.9, + }); + + const mesh = new THREE.Mesh(geometry, material); + + const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32); + const glowMaterial = new THREE.MeshBasicMaterial({ + color: n.color, + transparent: true, + opacity: 0.25, + }); + const glowMesh = new THREE.Mesh(glowGeometry, glowMaterial); + mesh.add(glowMesh); + + return mesh; + }) as any) .linkWidth(0) .linkColor(() => 'rgba(255,255,255,0.6)') .linkDirectionalParticles(2) .linkDirectionalParticleWidth(1.5) .linkDirectionalParticleSpeed(0.004) .linkDirectionalParticleColor(() => '#ffffff') - .onNodeClick((node, event) => { + .onNodeClick((node) => { + if (!node) return; const n = node as Graph3DNode; + if (!n.fullPath) return; focusNode(n.id); - setNodeInfo( - { - label: n.label, - fullPath: n.fullPath, - projectPath: n.projectPath, - kind: n.kind, - color: n.color, - fileId: n.fileId, - nodeSize: n.nodeSize, - }, - { x: event.clientX, y: event.clientY }, - ); + // 等相机飞行动画结束(800ms)后,用节点投影坐标打开 NodeInfo + setTimeout(() => { + const pos = nodeToScreenPos(n); + setNodeInfo( + { + label: n.label, + fullPath: n.fullPath, + projectPath: n.projectPath, + kind: n.kind, + color: n.color, + fileId: n.fileId, + nodeSize: n.nodeSize, + }, + pos, + ); + }, 850); }) .onNodeHover((node) => { if (el) el.style.cursor = node ? 'pointer' : 'default'; @@ -287,7 +345,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) { return () => { ro.disconnect(); }; - }, [files, focusNode, setNodeInfo]); + }, [files, focusNode, setNodeInfo, nodeToScreenPos]); // 卸载时销毁 useEffect(() => { @@ -297,6 +355,14 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) { }; }, []); + // 选中节点变化时刷新节点渲染以更新发光效果 + useEffect(() => { + const graph = graphRef.current; + if (graph) { + graph.refresh(); + } + }, [selectedNodeId]); + return (
@@ -304,7 +370,28 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) { focusNode(entry.nodeKey)} + onSelect={(entry) => { + focusNode(entry.nodeKey); + const graph = graphRef.current; + if (!graph) return; + const n = (graph.graphData().nodes as Graph3DNode[]).find((nd) => nd.id === entry.nodeKey); + if (!n) return; + setTimeout(() => { + const pos = nodeToScreenPos(n); + setNodeInfo( + { + label: n.label, + fullPath: n.fullPath, + projectPath: n.projectPath, + kind: n.kind, + color: n.color, + fileId: n.fileId, + nodeSize: n.nodeSize, + }, + pos, + ); + }, 850); + }} />
diff --git a/src/pages/code-graph/components/CodePod.tsx b/src/pages/code-graph/components/CodePod.tsx index 185dd8d..c50037c 100644 --- a/src/pages/code-graph/components/CodePod.tsx +++ b/src/pages/code-graph/components/CodePod.tsx @@ -1,4 +1,5 @@ -import { use, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Save, RefreshCw } from 'lucide-react'; import './CodePod.css'; import CodeMirror from '@uiw/react-codemirror'; import { vscodeDark } from '@uiw/codemirror-theme-vscode'; @@ -189,7 +190,12 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) { const rootPath = nodeAttrs?.fullPath ?? ''; const codeGraphStore = useCodeGraphStore(useShallow((s) => ({ getFiles: s.getFiles, + saveFile: s.saveFile, }))); + const projectPath = nodeAttrs?.projectPath ?? ''; + const displayPath = rootPath.startsWith(projectPath) + ? rootPath.slice(projectPath.length) || '/' + : rootPath; // 打开时重置 useEffect(() => { @@ -198,10 +204,10 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) { setDirFiles([]); setSelectedFile(null); setFileContent(''); - init(nodeAttrs); + refresh(nodeAttrs); setSidebarOpen(isDir ? true : false); }, [open, nodeAttrs]); - const init = async (nodeAttrs: GraphNode) => { + const refresh = async (nodeAttrs: GraphNode) => { setLoading(true); const res = await codeGraphStore.getFiles({ filepath: nodeAttrs.fullPath, @@ -218,7 +224,21 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) { if (fileList.length === 0) return; const file = fileList[0]; setSelectedFile(file); - } + }; + + const handleRefresh = () => { + if (!nodeAttrs) return; + setDirFiles([]); + setSelectedFile(null); + setFileContent(''); + refresh(nodeAttrs); + }; + + const handleSave = async () => { + const filepath = selectedFile?.filepath ?? (isDir ? '' : nodeAttrs?.fullPath ?? ''); + if (!filepath) return; + await codeGraphStore.saveFile(filepath, fileContent); + }; useEffect(() => { if (!selectedFile) return; if (selectedFile.content) { @@ -255,11 +275,11 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {