feat: 知识图谱改为 XMind 风格横向树(compactBox 布局,根在左向右展开)
This commit is contained in:
@@ -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 {} };
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user