import { useEffect, useRef, useState } from 'react'; import { Graph } from '@antv/g6'; import { invoke } from '../../ipc-client'; import { IPC, type GraphData, type GraphNode } from '@hunter/shared'; import { useStore } from '../../store'; // 按顶层分组固定坐标布局:每组一列,组内纵向排开,间距充足(FR-G2 组内分行排布) function layoutNodes(nodes: GraphNode[], width: number, height: number) { const groups = new Map(); nodes.forEach(n => { const a = groups.get(n.group) ?? []; a.push(n); groups.set(n.group, a); }); const groupList = [...groups.entries()]; const colWidth = groupList.length > 0 ? width / groupList.length : width; const positions = new Map(); groupList.forEach(([group, items], gi) => { const colX = colWidth * gi + colWidth / 2; const rowGap = Math.min(80, height / Math.max(items.length, 1)); const startY = height / 2 - (items.length * rowGap) / 2 + rowGap / 2; items.forEach((n, ni) => positions.set(n.id, { x: colX, y: startY + ni * rowGap })); }); return positions; } export function GraphView() { const containerRef = useRef(null); const [graph, setGraph] = useState(null); const { openDoc } = useStore(); const sizeRef = useRef({ w: 800, h: 600 }); useEffect(() => { if (!containerRef.current) return; const el = containerRef.current; const updateSize = () => { sizeRef.current = { w: el.clientWidth || 800, h: el.clientHeight || 600 }; }; updateSize(); const ro = new ResizeObserver(updateSize); ro.observe(el); const g = new Graph({ container: el, width: sizeRef.current.w, height: sizeRef.current.h, autoResize: true, layout: { type: 'grid' } as any, node: { style: { size: 28, fill: (d: any) => d.color || '#4a9eff', labelText: (d: any) => truncate(d.title, 10), labelFill: '#ddd', labelFontSize: 11, labelPlacement: 'bottom', labelOffsetY: 4, } } as any, edge: { style: { stroke: '#555', lineWidth: 1, endArrow: true } } as any, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'], }); setGraph(g); return () => { ro.disconnect(); g.destroy(); }; }, []); useEffect(() => { if (!graph) return; invoke(IPC.graph, undefined as never).then((data: GraphData) => { const pos = layoutNodes(data.nodes, sizeRef.current.w, sizeRef.current.h); graph.setData({ nodes: data.nodes.map(n => ({ id: n.id, x: pos.get(n.id)!.x, y: pos.get(n.id)!.y, style: { fill: n.color }, data: { title: n.title, star: n.star } })), edges: data.edges.map(e => ({ source: e.source, target: e.target })), } as any); graph.render(); }); graph.on('node:click', (e: any) => { openDoc(e.target.id); }); }, [graph]); return (
); } function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; }