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(null); const openDoc = useStore(s => s.openDoc); const [data, setData] = useState(null); const [err, setErr] = useState(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().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(); 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(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 (
{err &&
图谱加载失败:{err}
} {data && data.nodes.length === 0 &&
暂无文档(先建几篇笔记)
}
); } 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 };