diff --git a/apps/desktop/src/renderer/features/graph/GraphView.tsx b/apps/desktop/src/renderer/features/graph/GraphView.tsx index c72b1d0..c7325d1 100644 --- a/apps/desktop/src/renderer/features/graph/GraphView.tsx +++ b/apps/desktop/src/renderer/features/graph/GraphView.tsx @@ -4,26 +4,55 @@ import { invoke } from '../../ipc-client'; import { IPC, type TreeNode } from '@hunter/shared'; import { useStore } from '../../store'; -interface MindNode { id: string; style?: any; data?: any; children?: MindNode[]; } - -// 从扁平 TreeNode[] 构建 XMind 风格的树(根=库,文件夹=分支,文档=叶子) -function buildMindTree(nodes: TreeNode[]): MindNode { - const byParent = new Map(); - nodes.forEach(n => { const a = byParent.get(n.parent) ?? []; a.push(n); byParent.set(n.parent, a); }); - const build = (parentPath: string): MindNode[] => (byParent.get(parentPath) ?? []).map(n => { - const isFolder = n.type === 'folder'; - return { - id: n.path, - style: { fill: isFolder ? '#2a4a6a' : '#3a3a4a', stroke: isFolder ? '#4a9eff' : '#5a5a6a' }, - data: { title: n.name, type: n.type, star: false }, - children: isFolder ? build(n.path) : undefined, - }; - }); - return { id: '__root', style: { fill: '#1a3a5a', stroke: '#4a9eff' }, data: { title: '库', type: 'root' }, children: build('') }; -} +interface TNode { id: string; name: string; type: string; parentId: string | null; } function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; } +// 扁平 TreeNode[] → 带 parentId 的节点列表 + edges +function flatten(nodes: TreeNode[]): { nodes: TNode[]; edges: { source: string; target: string }[] } { + const out: TNode[] = []; const edges: { source: string; target: string }[] = []; + out.push({ id: '__root', name: '库', type: 'root', parentId: null }); + for (const n of nodes) { + const pid = n.parent === '' ? '__root' : n.parent; + out.push({ id: n.path, name: n.name, type: n.type, parentId: pid }); + edges.push({ source: pid, target: n.path }); + } + return { nodes: out, edges }; +} + +// 手动算横向树坐标:深度→x,同父同层→y 均匀分布 +function layoutTree(nodes: TNode[], w: number, h: number): Map { + const childrenOf = new Map(); + nodes.forEach(n => { const a = childrenOf.get(n.parentId ?? '__null') ?? []; a.push(n); childrenOf.set(n.parentId ?? '__null', a); }); + const pos = new Map(); + const maxDepth = Math.max(1, ...nodes.map(n => depth(n, nodes))); + const xStep = (w - 120) / maxDepth; + // 计算每个节点子树叶子数,用于纵向分配空间 + const leafCount = (id: string): number => { + const kids = childrenOf.get(id) ?? []; + if (kids.length === 0) return 1; + return kids.reduce((s, k) => s + leafCount(k.id), 0); + }; + const place = (id: string, depthNum: number, yStart: number, ySpan: number) => { + const kids = childrenOf.get(id) ?? []; + pos.set(id, { x: 60 + depthNum * xStep, y: yStart + ySpan / 2 }); + let accY = yStart; + for (const k of kids) { + const lc = leafCount(k.id); + place(k.id, depthNum + 1, accY, lc * (ySpan / Math.max(leafCount(id), 1))); + accY += lc * (ySpan / Math.max(leafCount(id), 1)); + } + }; + place('__root', 0, 40, h - 80); + return pos; +} + +function depth(n: TNode, all: TNode[]): number { + if (!n.parentId || n.parentId === '__root') return n.parentId === '__root' ? 1 : 0; + const p = all.find(x => x.id === n.parentId); + return p ? depth(p, all) + 1 : 1; +} + export function GraphView() { const containerRef = useRef(null); const { openDoc } = useStore(); @@ -37,42 +66,39 @@ export function GraphView() { container: el, width: 1200, height: 700, autoResize: true, - // 横向紧凑树(XMind 风格:根在左,向右展开) - layout: { - type: 'compactBox', - direction: 'LR', - getId: (d: any) => d.id, - getHeight: () => 28, - getWidth: () => 120, - getVGap: () => 12, - getHGap: () => 80, - } as any, node: { style: { - size: (d: any) => [120, 28] as any, + size: (d: any) => [130, 30] as any, fill: (d: any) => (d.style && d.style.fill) || '#3a3a4a', stroke: (d: any) => (d.style && d.style.stroke) || '#5a5a6a', - lineWidth: 1.5, - radius: 6, + lineWidth: 1.5, radius: 6, labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 14), labelFill: '#eee', labelFontSize: 12, labelPlacement: 'center', } } as any, - edge: { style: { stroke: '#4a5a7a', lineWidth: 1.5 } } as any, + edge: { style: { stroke: '#4a5a7a', lineWidth: 1.5, type: 'cubic' } } as any, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'], }); } catch (e) { console.error(e); return; } invoke(IPC.treeList, undefined as never).then((tree: TreeNode[]) => { - const mindRoot = buildMindTree(tree); - g.setData({ nodes: [mindRoot], edges: [] } as any); + const { nodes: flat, edges } = flatten(tree); + const rect = el.getBoundingClientRect(); + const w = rect.width || 1200, h = rect.height || 700; + const pos = layoutTree(flat, w, h); + const gNodes = flat.map(n => ({ + id: n.id, + x: pos.get(n.id)?.x ?? w / 2, + y: pos.get(n.id)?.y ?? h / 2, + style: { fill: n.type === 'root' ? '#1a3a5a' : n.type === 'folder' ? '#2a4a6a' : '#3a3a4a', stroke: n.type === 'root' ? '#4a9eff' : n.type === 'folder' ? '#4a9eff' : '#5a5a6a' }, + data: { title: n.name, type: n.type }, + })); + g.setData({ nodes: gNodes, edges } as any); g.render(); setTimeout(() => { try { g.fitView(60); } catch {} }, 500); }).catch(e => console.error(e)); - g.on('node:click', (e: any) => { - if (e.target && e.target.id && !e.target.id.startsWith('__')) openDoc(e.target.id); - }); + g.on('node:click', (e: any) => { if (e.target && e.target.id && !e.target.id.startsWith('__')) openDoc(e.target.id); }); return () => { try { g.destroy(); } catch {} }; }, []);