243 lines
14 KiB
TypeScript
243 lines
14 KiB
TypeScript
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 }; |