perf(renderer): 整体优化响应速度 — rAF 合并编辑器刷新/自动保存缓存/粒子引擎空闲自停
同时: 不透明窗口 + 精简主题(8到2)、TabBar 简化、图谱宽松布局、MoreActions 层级修复
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user