feat: 知识图谱改为 XMind 风格横向树(compactBox 布局,根在左向右展开)

This commit is contained in:
hunter-dev
2026-08-24 21:03:23 +08:00
parent b63df651d2
commit 39543a4456
@@ -1,29 +1,29 @@
import { useEffect, useRef } from 'react';
import { Graph } from '@antv/g6';
import { invoke } from '../../ipc-client';
import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
import { IPC, type TreeNode } from '@hunter/shared';
import { useStore } from '../../store';
function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; }
interface MindNode { id: string; style?: any; data?: any; children?: MindNode[]; }
// 按顶层分组:每组一列,列内纵向均匀排开。坐标基于画布尺寸,保证铺满不挤。
function layoutNodes(nodes: GraphNode[], w: number, h: number) {
const groups = new Map<string, GraphNode[]>();
nodes.forEach(n => { const a = groups.get(n.group) ?? []; a.push(n); groups.set(n.group, a); });
const groupList = [...groups.entries()];
const positions = new Map<string, { x: number; y: number }>();
if (groupList.length === 0 || w < 100 || h < 100) return positions;
const margin = 80;
const colWidth = (w - margin * 2) / Math.max(groupList.length, 1);
groupList.forEach(([, items], gi) => {
const colX = margin + colWidth * gi + colWidth / 2;
const usableH = h - margin * 2;
const rowGap = items.length > 1 ? usableH / (items.length + 1) : 0;
items.forEach((n, ni) => positions.set(n.id, { x: colX, y: margin + rowGap * (ni + 1) }));
// 从扁平 TreeNode[] 构建 XMind 风格的树(根=库,文件夹=分支,文档=叶子)
function buildMindTree(nodes: TreeNode[]): MindNode {
const byParent = new Map<string, TreeNode[]>();
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 positions;
return { id: '__root', style: { fill: '#1a3a5a', stroke: '#4a9eff' }, data: { title: '库', type: 'root' }, children: build('') };
}
function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; }
export function GraphView() {
const containerRef = useRef<HTMLDivElement>(null);
const { openDoc } = useStore();
@@ -37,37 +37,42 @@ 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: 32,
fill: (d: any) => (d.style && d.style.fill) || '#4a9eff',
stroke: '#6a8cff', lineWidth: 2,
labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 12),
labelFill: '#eee', labelFontSize: 12, labelPlacement: 'bottom', labelOffsetY: 6,
size: (d: any) => [120, 28] 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,
labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 14),
labelFill: '#eee', labelFontSize: 12, labelPlacement: 'center',
}
} as any,
edge: { style: { stroke: '#666', lineWidth: 1.5, endArrow: true } } as any,
edge: { style: { stroke: '#4a5a7a', lineWidth: 1.5 } } as any,
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'],
});
} catch (e) { console.error(e); return; }
invoke(IPC.graph, undefined as never).then((data: GraphData) => {
const rect = el.getBoundingClientRect();
const w = rect.width || 1200, h = rect.height || 700;
const pos = layoutNodes(data.nodes, w, h);
const nodes = data.nodes.map(n => ({
id: n.id,
x: pos.get(n.id)?.x ?? w / 2,
y: pos.get(n.id)?.y ?? h / 2,
style: { fill: n.color, stroke: n.star ? '#ffd54a' : '#6a8cff' },
data: { title: n.title, star: n.star },
}));
const edges = data.edges.map(e => ({ source: e.source, target: e.target }));
g.setData({ nodes, edges } as any);
invoke(IPC.treeList, undefined as never).then((tree: TreeNode[]) => {
const mindRoot = buildMindTree(tree);
g.setData({ nodes: [mindRoot], 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) 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 {} };
}, []);