fix: 图谱手动算横向树坐标铺开,修复只显示根节点
This commit is contained in:
@@ -4,26 +4,55 @@ import { invoke } from '../../ipc-client';
|
|||||||
import { IPC, type TreeNode } from '@hunter/shared';
|
import { IPC, type TreeNode } from '@hunter/shared';
|
||||||
import { useStore } from '../../store';
|
import { useStore } from '../../store';
|
||||||
|
|
||||||
interface MindNode { id: string; style?: any; data?: any; children?: MindNode[]; }
|
interface TNode { id: string; name: string; type: string; parentId: string | null; }
|
||||||
|
|
||||||
// 从扁平 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 { 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; }
|
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<string, { x: number; y: number }> {
|
||||||
|
const childrenOf = new Map<string, TNode[]>();
|
||||||
|
nodes.forEach(n => { const a = childrenOf.get(n.parentId ?? '__null') ?? []; a.push(n); childrenOf.set(n.parentId ?? '__null', a); });
|
||||||
|
const pos = new Map<string, { x: number; y: number }>();
|
||||||
|
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() {
|
export function GraphView() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { openDoc } = useStore();
|
const { openDoc } = useStore();
|
||||||
@@ -37,42 +66,39 @@ export function GraphView() {
|
|||||||
container: el,
|
container: el,
|
||||||
width: 1200, height: 700,
|
width: 1200, height: 700,
|
||||||
autoResize: true,
|
autoResize: true,
|
||||||
// 横向紧凑树(XMind 风格:根在左,向右展开)
|
|
||||||
layout: {
|
|
||||||
type: 'compactBox',
|
|
||||||
direction: 'LR',
|
|
||||||
getId: (d: any) => d.id,
|
|
||||||
getHeight: () => 28,
|
|
||||||
getWidth: () => 120,
|
|
||||||
getVGap: () => 12,
|
|
||||||
getHGap: () => 80,
|
|
||||||
} as any,
|
|
||||||
node: {
|
node: {
|
||||||
style: {
|
style: {
|
||||||
size: (d: any) => [120, 28] as any,
|
size: (d: any) => [130, 30] as any,
|
||||||
fill: (d: any) => (d.style && d.style.fill) || '#3a3a4a',
|
fill: (d: any) => (d.style && d.style.fill) || '#3a3a4a',
|
||||||
stroke: (d: any) => (d.style && d.style.stroke) || '#5a5a6a',
|
stroke: (d: any) => (d.style && d.style.stroke) || '#5a5a6a',
|
||||||
lineWidth: 1.5,
|
lineWidth: 1.5, radius: 6,
|
||||||
radius: 6,
|
|
||||||
labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 14),
|
labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 14),
|
||||||
labelFill: '#eee', labelFontSize: 12, labelPlacement: 'center',
|
labelFill: '#eee', labelFontSize: 12, labelPlacement: 'center',
|
||||||
}
|
}
|
||||||
} as any,
|
} 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'],
|
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'],
|
||||||
});
|
});
|
||||||
} catch (e) { console.error(e); return; }
|
} catch (e) { console.error(e); return; }
|
||||||
|
|
||||||
invoke(IPC.treeList, undefined as never).then((tree: TreeNode[]) => {
|
invoke(IPC.treeList, undefined as never).then((tree: TreeNode[]) => {
|
||||||
const mindRoot = buildMindTree(tree);
|
const { nodes: flat, edges } = flatten(tree);
|
||||||
g.setData({ nodes: [mindRoot], edges: [] } as any);
|
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();
|
g.render();
|
||||||
setTimeout(() => { try { g.fitView(60); } catch {} }, 500);
|
setTimeout(() => { try { g.fitView(60); } catch {} }, 500);
|
||||||
}).catch(e => console.error(e));
|
}).catch(e => console.error(e));
|
||||||
|
|
||||||
g.on('node:click', (e: any) => {
|
g.on('node:click', (e: any) => { if (e.target && e.target.id && !e.target.id.startsWith('__')) openDoc(e.target.id); });
|
||||||
if (e.target && e.target.id && !e.target.id.startsWith('__')) openDoc(e.target.id);
|
|
||||||
});
|
|
||||||
return () => { try { g.destroy(); } catch {} };
|
return () => { try { g.destroy(); } catch {} };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user