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
+8
View File
@@ -63,10 +63,18 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@types/d3-drag": "^3.0.7",
"@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.11",
"@types/d3-zoom": "^3.0.8",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"d3-drag": "^3.0.0",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"electron": "^30.0.0", "electron": "^30.0.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"electron-vite": "^2.3.0", "electron-vite": "^2.3.0",
@@ -1,9 +1,10 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Markmap } from 'markmap-view'; import { Markmap } from 'markmap-view';
import { useStore } from '../../store'; import { useStore } from '../../store';
import type { TreeNode } from '@hunter/shared'; import type { TreeNode } from '@hunter/shared';
import { LinkGraph } from './LinkGraph';
// markmap 的纯数据节点(与 markmap-common IPureNode 结构一致:content/children/payload) // markmap 的纯数据节点(content/children/payload)
type PureNode = { type PureNode = {
content: string; content: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
@@ -15,7 +16,6 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
type NodeType = 'root' | 'folder' | 'doc'; type NodeType = 'root' | 'folder' | 'doc';
// 把扁平 TreeNode[](parent 引用)转成 markmap 的树
function toMarkmapTree(nodes: TreeNode[]): PureNode { function toMarkmapTree(nodes: TreeNode[]): PureNode {
const byParent = new Map<string, TreeNode[]>(); const byParent = new Map<string, TreeNode[]>();
for (const n of nodes) { for (const n of nodes) {
@@ -23,22 +23,16 @@ function toMarkmapTree(nodes: TreeNode[]): PureNode {
arr.push(n); arr.push(n);
byParent.set(n.parent, arr); byParent.set(n.parent, arr);
} }
const build = (path: string, type: NodeType, name: string): PureNode => { const build = (path: string, type: NodeType, name: string): PureNode => {
const cls = type === 'root' ? 'mm-root' : type === 'folder' ? 'mm-folder' : 'mm-doc'; const cls = type === 'root' ? 'mm-root' : type === 'folder' ? 'mm-folder' : 'mm-doc';
const icon = type === 'root' ? '🗂' : type === 'folder' ? '📁' : '📄'; const icon = type === 'root' ? '🗂' : type === 'folder' ? '📁' : '📄';
const kids = type === 'doc' ? [] : (byParent.get(path) ?? []); const kids = type === 'doc' ? [] : (byParent.get(path) ?? []);
return { return {
content: content: '<span class="mm-node ' + cls + '"><span class="mm-ico">' + icon + '</span><span class="mm-name">' + escapeHtml(name) + '</span></span>',
'<span class="mm-node ' + cls + '">' +
'<span class="mm-ico">' + icon + '</span>' +
'<span class="mm-name">' + escapeHtml(name) + '</span>' +
'</span>',
payload: { type, path, name }, payload: { type, path, name },
children: kids.map((k) => build(k.path, k.type, k.name)), children: kids.map((k) => build(k.path, k.type, k.name)),
}; };
}; };
return build('', 'root', '库'); return build('', 'root', '库');
} }
@@ -48,7 +42,6 @@ const MARKMAP_OPTIONS = {
initialExpandLevel: 2, initialExpandLevel: 2,
maxWidth: 320, maxWidth: 320,
paddingX: 12, paddingX: 12,
// 颜色:根/文件夹为蓝色系,文档为灰,避免默认彩虹色干扰阅读
color: (node: { payload?: Record<string, unknown> }) => { color: (node: { payload?: Record<string, unknown> }) => {
const t = node.payload?.type as NodeType | undefined; const t = node.payload?.type as NodeType | undefined;
if (t === 'doc') return '#9aa0aa'; if (t === 'doc') return '#9aa0aa';
@@ -57,24 +50,19 @@ const MARKMAP_OPTIONS = {
}, },
}; };
export function GraphView() { function MindmapTree() {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const mmRef = useRef<Markmap | null>(null); const mmRef = useRef<Markmap | null>(null);
const { tree, openDoc } = useStore(); const { tree, openDoc } = useStore();
// 建一次 markmap 实例 + 点击委托
useEffect(() => { useEffect(() => {
const svg = svgRef.current; const svg = svgRef.current;
if (!svg) return; if (!svg) return;
const mm = new Markmap(svg, MARKMAP_OPTIONS); const mm = new Markmap(svg, MARKMAP_OPTIONS);
mmRef.current = mm; mmRef.current = mm;
// 点击文档节点 → 打开;点击文件夹文字 → 折叠/展开(与圆点行为一致)
const onClick = (e: MouseEvent) => { const onClick = (e: MouseEvent) => {
const target = e.target as Element | null; const target = e.target as Element | null;
if (!target) return; if (!target) return;
// 折叠圆点交给 markmap 自身处理,避免双重 toggle
if ((target as SVGElement).tagName === 'circle') return; if ((target as SVGElement).tagName === 'circle') return;
const nodeEl = (target as HTMLElement).closest?.('.mm-node') as Element | null; const nodeEl = (target as HTMLElement).closest?.('.mm-node') as Element | null;
if (!nodeEl) return; if (!nodeEl) return;
@@ -90,7 +78,6 @@ export function GraphView() {
} }
}; };
svg.addEventListener('click', onClick, true); svg.addEventListener('click', onClick, true);
return () => { return () => {
svg.removeEventListener('click', onClick, true); svg.removeEventListener('click', onClick, true);
try { mm.destroy(); } catch {} try { mm.destroy(); } catch {}
@@ -98,7 +85,6 @@ export function GraphView() {
}; };
}, [openDoc]); }, [openDoc]);
// tree 变化时刷新数据(新建/删除/重命名后自动更新,无需重新进入图谱视图)
useEffect(() => { useEffect(() => {
const mm = mmRef.current; const mm = mmRef.current;
if (!mm || tree.length === 0) return; if (!mm || tree.length === 0) return;
@@ -123,3 +109,20 @@ export function GraphView() {
</div> </div>
); );
} }
function tabBtn(active: boolean): React.CSSProperties {
return { padding: '3px 10px', fontSize: 12, background: active ? '#2a2a3a' : 'transparent', color: active ? '#fff' : '#888', border: '1px solid #2a2a3a', borderRadius: 4, cursor: 'pointer' };
}
export function GraphView() {
const [mode, setMode] = useState<'mindmap' | 'graph'>('mindmap');
return (
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: '#111' }}>
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 10, display: 'flex', gap: 4 }}>
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
</div>
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph />}
</div>
);
}
@@ -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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
test('知识图谱:卡片+连线(消费 IPC.graph)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-graph-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await a.invoke('file:create', { basePath: '', name: 'a', isFolder: false });
await a.invoke('file:create', { basePath: '', name: 'b', isFolder: false });
await a.invoke('doc:write', { path: 'a.md', meta: { title: 'A篇', links: ['b.md'] }, body: '# A\n' });
await a.invoke('doc:write', { path: 'b.md', meta: { title: 'B篇' }, body: '# B\n' });
// IPC.graph:2 节点 + a→b 边
const gd: { nodes: { id: string; title: string; star: boolean; color: string }[]; edges: { source: string; target: string }[] } = await a.invoke('graph', null);
expect(gd.nodes.length).toBe(2);
expect(gd.nodes.some(n => n.title === 'A篇')).toBe(true);
expect(gd.edges.some(e => e.source === 'a.md' && e.target === 'b.md')).toBe(true);
// UI:先经 TitleBar 切到图谱视图(view 默认 editor),再切「知识图谱」模式
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
await expect(a.page.locator('svg rect')).toHaveCount(4, { timeout: 10000 });
await a.app.close();
});
+61
View File
@@ -120,6 +120,18 @@ importers:
'@playwright/test': '@playwright/test':
specifier: ^1.62.1 specifier: ^1.62.1
version: 1.62.1 version: 1.62.1
'@types/d3-drag':
specifier: ^3.0.7
version: 3.0.7
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
'@types/d3-selection':
specifier: ^3.0.11
version: 3.0.11
'@types/d3-zoom':
specifier: ^3.0.8
version: 3.0.8
'@types/node': '@types/node':
specifier: ^20.0.0 specifier: ^20.0.0
version: 20.19.43 version: 20.19.43
@@ -132,6 +144,18 @@ importers:
cross-env: cross-env:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0 version: 10.1.0
d3-drag:
specifier: ^3.0.0
version: 3.0.0
d3-force:
specifier: ^3.0.0
version: 3.0.0
d3-selection:
specifier: ^3.0.0
version: 3.0.0
d3-zoom:
specifier: ^3.0.0
version: 3.0.0
electron: electron:
specifier: ^30.0.0 specifier: ^30.0.0
version: 30.5.1(supports-color@7.2.0) version: 30.5.1(supports-color@7.2.0)
@@ -919,6 +943,24 @@ packages:
'@types/cacheable-request@6.0.3': '@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/debug@4.1.13': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -3512,6 +3554,25 @@ snapshots:
'@types/node': 20.19.43 '@types/node': 20.19.43
'@types/responselike': 1.0.3 '@types/responselike': 1.0.3
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-force@3.0.10': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-selection@3.0.11': {}
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/debug@4.1.13': '@types/debug@4.1.13':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0