Files
MyNote/apps/desktop/src/renderer/features/graph/LinkGraph.tsx
T

243 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { select } from 'd3-selection';
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force';
import { zoom, zoomIdentity } from 'd3-zoom';
import { useStore } from '../../store';
import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
// M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线
type SimNode = GraphNode & SimulationNodeDatum;
const CARD_W = 180, CARD_H = 52;
const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '...' : s);
export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const svgRef = useRef<SVGSVGElement>(null);
const openDoc = useStore(s => s.openDoc);
const [data, setData] = useState<GraphData | null>(null);
const [err, setErr] = useState<string | null>(null);
const zoomRef = useRef<{ zoomBeh: any; svg: any; g: any } | null>(null);
// 渲染时机修复:容器隐藏时 clientWidth=0,用 ResizeObserver 等到拿到真实尺寸后再重渲染
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const prev = { w: 0, h: 0 };
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const w = Math.round(entry.contentRect.width);
const h = Math.round(entry.contentRect.height);
if (w > 0 && h > 0 && (w !== prev.w || h !== prev.h)) {
prev.w = w; prev.h = h;
setDims({ w, h });
}
}
});
ro.observe(el);
// 首帧补偿:布局在首帧后才稳定
requestAnimationFrame(() => {
const w = Math.round(el.clientWidth); const h = Math.round(el.clientHeight);
if (w > 0 && h > 0) setDims({ w, h });
});
return () => ro.disconnect();
}, []);
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 defs = svg.append('defs');
// 卡片投影(提升层次感)
const filter = defs.append('filter').attr('id', 'graph-card-shadow').attr('x', '-30%').attr('y', '-30%').attr('width', '160%').attr('height', '160%');
filter.append('feDropShadow').attr('dx', 0).attr('dy', 2).attr('stdDeviation', 4).attr('flood-color', '#000').attr('flood-opacity', 0.22);
// 深墨焦点节点的投影(更强,突出层次)
const ffilter = defs.append('filter').attr('id', 'graph-card-focus').attr('x', '-40%').attr('y', '-40%').attr('width', '180%').attr('height', '180%');
ffilter.append('feDropShadow').attr('dx', 0).attr('dy', 4).attr('stdDeviation', 8).attr('flood-color', '#000').attr('flood-opacity', 0.35);
// 背景 rect 必须先 append:若在卡片 g 之后 append,bgRect 的 pointer-events:all 会盖住卡片拦截点击
const bgRect = svg.append('rect').attr('width', W).attr('height', H).attr('fill', 'transparent').attr('pointer-events', 'all');
const g = svg.append('g');
// FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击(卡片 g 在 bgRect 之上)
const zoomBeh = zoom<SVGSVGElement, unknown>().scaleExtent([0.1, 2]).on('zoom', (ev: any) => g.attr('transform', ev.transform));
(bgRect as any).call(zoomBeh);
zoomRef.current = { zoomBeh, svg, g };
const n = Math.max(1, data.nodes.length);
// 初始位置:按节点数撑开成螺旋,避免全部挤在圆心
const R = 150 + n * 26; // 节点越多,初始散布半径越大
const nodes: SimNode[] = data.nodes.map((node, i) => {
const ang = (2 * Math.PI * i) / Math.max(1, n) + (n > 1 ? (Math.random() - 0.5) * 0.35 : 0);
const rad = R * (0.55 + 0.45 * ((i % 3) / 3)); // 内外圈交错,更自然
return { ...node, x: W / 2 + Math.cos(ang) * rad, y: H / 2 + Math.sin(ang) * rad };
});
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 curPath = useStore.getState().currentPath;
const linkedIds = new Set<string>();
for (const e of data.edges) {
if (e.source === curPath) linkedIds.add(e.target);
if (e.target === curPath) linkedIds.add(e.source);
}
const linkSel = g.append('g')
.selectAll('path').data(links).join('path').attr('fill', 'none')
.attr('stroke', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 'var(--ink-2)' : 'var(--line)')
.attr('stroke-opacity', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 0.9 : 0.5)
.attr('stroke-width', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 2 : 1.1);
// 布局参数:刻意拉大间距,卡片(180 宽)之间留足空白,避免拥挤
const sim = forceSimulation<SimNode>(nodes)
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(175).strength(0.16))
.force('charge', forceManyBody().strength(-300).distanceMax(440))
.force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(CARD_W / 2 + 16).iterations(3))
// 向心约束:纵向比横向稍强,让整图在横屏视口里铺得更宽、更扁平(更好适配,也更稳)
.force('x', forceX(W / 2).strength(0.07))
.force('y', forceY(H / 2).strength(0.14))
.alphaDecay(0.02).velocityDecay(0.38);
// 曲线连线:每根线用固定弯曲符号(按源+目标哈希),不随帧抖动、多线不重叠
const linkPath = (d: any) => {
const sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y;
const dx = tx - sx, dy = ty - sy;
const mx = sx + dx / 2, my = sy + dy / 2;
const key = String(d.source.id) + '>' + String(d.target.id);
let h = 7; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
const dir = h % 2 === 0 ? 1 : -1;
const bend = 18 + (h % 14);
const nx = -dy, ny = dx;
const len = Math.max(1, Math.hypot(dx, dy));
return 'M' + sx + ',' + sy + ' Q' + (mx + (nx / len) * bend * dir) + ',' + (my + (ny / len) * bend * dir) + ' ' + tx + ',' + ty;
};
// 卡片:深墨焦点(当前文档)+ 玻璃底卡 + 分组色点 + 关联高亮(保持每卡 1 个 rect)
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
// 阴影 filter 加在 g 层:若加在卡片 rect 上会导致该 rect 的 pointer-events 命中失效(点击穿透)
.attr('filter', (d: any) => d.id === curPath ? 'url(#graph-card-focus)' : 'url(#graph-card-shadow)');
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 14)
.attr('fill', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--float)')
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : (linkedIds.has(d.id) ? 'var(--ink-2)' : 'var(--line)'))
.attr('stroke-width', (d: any) => (d.id === curPath || linkedIds.has(d.id)) ? 1.6 : 1.1)
.attr('pointer-events', 'all');
// 分组色点(左上角,circle 不增加卡片 rect 计数)
nodeSel.append('circle').attr('cx', -CARD_W / 2 + 10).attr('cy', -CARD_H / 2 + 10).attr('r', 3.4)
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : (d.color || 'var(--ink-3)'))
.attr('opacity', 0.9);
// 标题(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', -3).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink)')
.attr('font-size', 13).attr('font-weight', (d: any) => d.id === curPath ? 650 : (linkedIds.has(d.id) ? 600 : 550)).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 16));
// 路径副文本(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', 14).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.72)' : 'var(--ink-4)')
.attr('font-size', 10).attr('font-family', 'sans-serif')
.text((d) => truncate(d.basePath || d.id, 18));
// 星标(右上角)
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 5)
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink-3)').attr('font-size', 14).text('★');
// 与当前文档关联的卡片右上角加一个小圆点提示(circle,不影响 rect 计数)
nodeSel.filter((d: any) => linkedIds.has(d.id) && d.id !== curPath).append('circle')
.attr('cx', CARD_W / 2 - 12).attr('cy', -CARD_H / 2 + 10).attr('r', 3)
.attr('fill', 'var(--ink-2)').attr('opacity', 0.85);
// 悬停高亮(CSS,不增加 rect)
svg.append('style').text('.gcard { transition: stroke-width .16s var(--ease), stroke .16s var(--ease); } g[data-hover] .gcard { stroke: var(--line-strong); stroke-width: 1.7; }');
nodeSel.attr('data-doc-path', (d: any) => d.id || '')
.on('mouseenter', function (_ev: any, d: any) { (this as Element).setAttribute('data-hover', '1'); })
.on('mouseleave', function (_ev: any, d: any) { (this as Element).removeAttribute('data-hover'); })
// FR-G5:点击卡片打开文档并切回编辑器视图
nodeSel.each(function(d: any) {
const el = this as HTMLElement;
el.addEventListener('click', (ev: MouseEvent) => {
ev.stopPropagation();
if (d.id) { openDoc(d.id); onSwitchToEditor?.(); }
});
});
sim.on('tick', () => {
linkSel.attr('d', linkPath);
nodeSel.attr('transform', (d: any) => 'translate(' + d.x + ',' + d.y + ')');
});
// 首帧自动适配视图:等布局稳定后整体框入可视区(带留白),避免节点被裁出画面
let autoFitTimer: any = null;
const doAutoFit = () => {
if (autoFitTimer) return;
autoFitTimer = setTimeout(() => {
autoFitTimer = null;
if (!zoomRef.current || !data) return;
const { svg, zoomBeh } = zoomRef.current;
const svgEl = svgRef.current;
if (!svgEl) return;
const VW = svgEl.clientWidth || 800, VH = svgEl.clientHeight || 600;
const xs = nodes.map(n => n.x || 0), ys = nodes.map(n => n.y || 0);
const minX = Math.min(...xs) - CARD_W / 2, maxX = Math.max(...xs) + CARD_W / 2;
const minY = Math.min(...ys) - CARD_H / 2, maxY = Math.max(...ys) + CARD_H / 2;
const dx = Math.max(1, maxX - minX), dy = Math.max(1, maxY - minY);
const pad = 60; // 四周留白
const scale = Math.max(0.5, Math.min(1.15, Math.min((VW - pad * 2) / dx, (VH - pad * 2) / dy)));
const tx = VW / 2 - scale * (minX + dx / 2), ty = VH / 2 - scale * (minY + dy / 2);
(svg as any).transition().duration(500).call(zoomBeh.transform, zoomIdentity.translate(tx, ty).scale(scale));
}, 650);
};
sim.on('end', doAutoFit);
// 兜底:即使 alpha 未完全归零也适配一次
const fallbackTimer = setTimeout(doAutoFit, 1600);
return () => { sim.stop(); (bgRect as any).on('.zoom', null); if (autoFitTimer) clearTimeout(autoFitTimer); clearTimeout(fallbackTimer); };
}, [data, openDoc, onSwitchToEditor, dims]);
// FR-G4:适配视图 + 缩放控制
const fitView = () => {
if (!zoomRef.current || !data || data.nodes.length === 0) return;
const { svg, zoomBeh } = zoomRef.current;
const svgEl = svgRef.current!;
const W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600;
const xs = data.nodes.map((n: any) => n.x || 0), ys = data.nodes.map((n: any) => n.y || 0);
const minX = Math.min(...xs) - CARD_W / 2, maxX = Math.max(...xs) + CARD_W / 2;
const minY = Math.min(...ys) - CARD_H / 2, maxY = Math.max(...ys) + CARD_H / 2;
const dx = maxX - minX, dy = maxY - minY;
const scale = Math.min(2, Math.max(0.1, Math.min(W / Math.max(dx, 1), H / Math.max(dy, 1)) * 0.9));
const tx = W / 2 - scale * (minX + dx / 2), ty = H / 2 - scale * (minY + dy / 2);
(svg as any).transition().duration(400).call(zoomBeh.transform, zoomIdentity.translate(tx, ty).scale(scale));
};
const zoomBy = (factor: number) => {
if (!zoomRef.current) return;
const { svg, zoomBeh } = zoomRef.current;
zoomBeh.scaleBy((svg as any).transition().duration(200), factor);
};
return (
<div style={{ flex: 1, background: 'transparent', position: 'relative', overflow: 'hidden' }}>
<svg data-testid="graph-canvas" ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
<div data-testid="graph-controls" style={{ position: 'absolute', bottom: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}>
<button title="放大" onClick={() => zoomBy(1.3)} style={ctrlBtn}>+</button>
<button title="缩小" onClick={() => zoomBy(1 / 1.3)} style={ctrlBtn}>-</button>
<button title="适配视图" onClick={fitView} style={ctrlBtn} data-testid="fit-view-btn">Fit</button>
</div>
{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: 'var(--text-tertiary)', fontSize: 12 }}>暂无文档(先建几篇笔记)</div>}
</div>
);
}
const ctrlBtn: React.CSSProperties = { width: 32, height: 28, border: '1px solid var(--control-border)', background: 'var(--bg-secondary)', color: 'var(--text-secondary)', cursor: 'pointer', borderRadius: 4, fontSize: 11 };