perf(renderer): 整体优化响应速度 — rAF 合并编辑器刷新/自动保存缓存/粒子引擎空闲自停

同时: 不透明窗口 + 精简主题(8到2)、TabBar 简化、图谱宽松布局、MoreActions 层级修复
This commit is contained in:
hunter-dev
2026-09-10 00:41:24 +08:00
parent d0dbdfd00a
commit c0ba8df9b6
27 changed files with 417 additions and 178 deletions
@@ -1,7 +1,7 @@
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 { 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';
@@ -17,6 +17,30 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
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;
@@ -40,63 +64,88 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
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.28);
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 上,不拦截卡片点击
// 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));
const bgRect = svg.append('rect').attr('width', W).attr('height', H).attr('fill', 'transparent').attr('pointer-events', 'all');
(bgRect as any).call(zoomBeh);
zoomRef.current = { zoomBeh, svg, g };
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) * 230,
y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 230,
}));
// 初始位置:按节点数撑开成螺旋,避免全部挤在圆心
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 linkSel = g.append('g').attr('stroke', 'var(--control-border)').attr('stroke-opacity', 0.55)
.selectAll('line').data(links).join('line').attr('stroke-width', 1.2);
// 曲线连线(贝塞尔弧):比直线更优雅、更符合"图谱"观感
const linkSel = g.append('g').attr('stroke', 'var(--line)').attr('stroke-opacity', 0.6)
.selectAll('path').data(links).join('path').attr('fill', 'none').attr('stroke-width', 1.1);
// 布局参数:刻意拉大间距,卡片(180 宽)之间留足空白,避免拥挤
const sim = forceSimulation<SimNode>(nodes)
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(140).strength(0.2))
.force('charge', forceManyBody().strength(-360))
.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(105));
.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 nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer');
// 曲线连线:每根线用固定弯曲符号(按源+目标哈希),不随帧抖动、多线不重叠
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;
};
// 当前打开的文档作为深墨焦点节点
const curPath = useStore.getState().currentPath;
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', 9)
.attr('fill', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').attr('stroke-width', 1.4)
.attr('filter', 'url(#graph-card-shadow)');
// 左色条(圆角左侧)
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2 + 4).attr('width', 4).attr('height', CARD_H - 8).attr('rx', 2).attr('fill', (d) => d.color || '#5db0ff');
// 文件图标(lucide file 路径,不占 rect)
nodeSel.append('path')
.attr('d', 'M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z')
.attr('transform', 'translate(' + (-CARD_W / 2 + 18) + ',' + (-CARD_H / 2 + 8) + ') scale(1)')
.attr('fill', 'none').attr('stroke', (d) => d.color || '#5db0ff').attr('stroke-width', 1.8).attr('stroke-linejoin', 'round');
nodeSel.append('path')
.attr('d', 'M13 2v6h6')
.attr('transform', 'translate(' + (-CARD_W / 2 + 18) + ',' + (-CARD_H / 2 + 8) + ')')
.attr('fill', 'none').attr('stroke', (d) => d.color || '#5db0ff').attr('stroke-width', 1.8).attr('stroke-linejoin', 'round');
// 标题
nodeSel.append('text').attr('x', -CARD_W / 2 + 34).attr('y', -4).attr('fill', 'var(--text-primary)').attr('font-size', 13).attr('font-weight', 600).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 15));
// 路径副文本
nodeSel.append('text').attr('x', -CARD_W / 2 + 34).attr('y', 13).attr('fill', 'var(--text-tertiary)').attr('font-size', 9).attr('font-family', 'sans-serif')
.text((d) => truncate(d.basePath || d.id, 16));
// 星标
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', '#f5c518').attr('font-size', 14).text('★');
// 卡片:圆角底卡 + 标题 + 路径 + 星标(无图标,更简洁);当前文档 = 深墨焦点节点,文字居中
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', 13)
.attr('fill', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--float)')
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--line)')
.attr('stroke-width', 1.2)
.attr('pointer-events', 'all');
// 标题(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', -2).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 ? 600 : 550).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 16));
// 路径副文本(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', 13).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.7)' : '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', 4)
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink-3)').attr('font-size', 14).text('★');
// 悬停高亮(CSS,不增加 rect)
svg.append('style').text('.gcard { transition: stroke-width .12s ease; } g[data-hover] .gcard { stroke-width: 2.2; }');
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'); })
@@ -111,12 +160,35 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
});
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);
linkSel.attr('d', linkPath);
nodeSel.attr('transform', (d: any) => 'translate(' + d.x + ',' + d.y + ')');
});
return () => { sim.stop(); (bgRect as any).on('.zoom', null); };
}, [data, openDoc, onSwitchToEditor]);
// 首帧自动适配视图:等布局稳定后整体框入可视区(带留白),避免节点被裁出画面
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 = () => {
@@ -130,7 +202,7 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
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, (window as any).d3?.zoomIdentity?.translate(tx, ty).scale(scale) || { k: scale, x: tx, y: ty });
(svg as any).transition().duration(400).call(zoomBeh.transform, zoomIdentity.translate(tx, ty).scale(scale));
};
const zoomBy = (factor: number) => {
if (!zoomRef.current) return;
@@ -139,7 +211,7 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
};
return (
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<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>