feat: M7 知识图谱卡片+连线视图(消费 IPC.graph,d3-force 力导向,星标/分组色/点击打开;GraphView 思维导图/知识图谱 双模切换)

This commit is contained in:
hunter-dev
2026-08-24 23:33:46 +08:00
parent b9aefa3c17
commit 28755547c1
5 changed files with 234 additions and 19 deletions
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { select } from 'd3-selection';
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type SimulationNodeDatum } from 'd3-force';
import { zoom } from 'd3-zoom';
import { drag } from 'd3-drag';
import { useStore } from '../../store';
import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
// M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线
// 卡片颜色取自后端按顶层文件夹分配的 12 色轮;星标文档带 ★;点击卡片打开文档
type SimNode = GraphNode & SimulationNodeDatum;
const CARD_W = 160, CARD_H = 34;
const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
export function LinkGraph() {
const svgRef = useRef<SVGSVGElement>(null);
const { openDoc } = useStore();
const [data, setData] = useState<GraphData | null>(null);
const [err, setErr] = useState<string | null>(null);
// 拉取图数据
useEffect(() => {
let alive = true;
(async () => {
try {
const d = (await (globalThis as any).dsh.invoke(IPC.graph)) as GraphData;
if (alive) setData(d);
} catch (e: any) {
if (alive) setErr(e?.message ?? String(e));
}
})();
return () => { alive = false; };
}, []);
// 建模拟 + 渲染
useEffect(() => {
if (!data || !svgRef.current) return;
const svgEl = svgRef.current;
const W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600;
const svg = select(svgEl);
svg.selectAll('*').remove();
const g = svg.append('g');
// 平移/缩放
const zoomBeh = zoom<SVGSVGElement, unknown>().scaleExtent([0.2, 4]).on('zoom', (ev: any) => g.attr('transform', ev.transform));
(svg as any).call(zoomBeh);
const n = Math.max(1, data.nodes.length);
const nodes: SimNode[] = data.nodes.map((node, i) => ({
...node,
x: W / 2 + Math.cos((2 * Math.PI * i) / n) * 220,
y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 220,
}));
const idx = new Map(nodes.map((nd) => [nd.id, nd] as const));
const links = data.edges
.filter((e) => idx.has(e.source) && idx.has(e.target))
.map((e) => ({ source: idx.get(e.source)!, target: idx.get(e.target)! }));
const linkSel = g.append('g').attr('stroke', '#3a3a4a').attr('stroke-opacity', 0.6)
.selectAll('line').data(links).join('line').attr('stroke-width', 1);
// 力导向模拟(先建,拖拽回调引用之)
const sim = forceSimulation<SimNode>(nodes)
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(130).strength(0.2))
.force('charge', forceManyBody().strength(-360))
.force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(85));
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
.call(drag<SVGGElement, SimNode>()
.on('start', (ev: any, d: any) => { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (ev: any, d: any) => { d.fx = ev.x; d.fy = ev.y; })
.on('end', (ev: any, d: any) => { if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }) as any);
// 卡片:外框(描边=分组色)+ 左侧色条 + 标题 + 星标
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 5)
.attr('fill', '#1f1f2a').attr('stroke', (d) => d.color || '#3a3a4a').attr('stroke-width', 1.5);
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', 4).attr('height', CARD_H).attr('fill', (d) => d.color || '#5db0ff');
nodeSel.append('text').attr('x', -CARD_W / 2 + 12).attr('y', 2).attr('fill', '#ddd').attr('font-size', 12).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 16));
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', '#f5c542').attr('font-size', 12).text('★');
nodeSel.on('click', (_ev: any, d: any) => { if (d.basePath) openDoc(d.basePath); });
sim.on('tick', () => {
linkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y).attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
nodeSel.attr('transform', (d: any) => 'translate(' + d.x + ',' + d.y + ')');
});
return () => { sim.stop(); (svg as any).on('.zoom', null); };
}, [data, openDoc]);
return (
<div style={{ flex: 1, background: '#111', position: 'relative', overflow: 'hidden' }}>
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
{err && <div style={{ position: 'absolute', top: 8, left: 8, color: '#f88', fontSize: 12 }}>图谱加载失败:{err}</div>}
{data && data.nodes.length === 0 && <div style={{ position: 'absolute', top: 8, left: 8, color: '#888', fontSize: 12 }}>暂无文档(先建几篇笔记)</div>}
</div>
);
}