feat(graph): 优化思维导图/知识图谱节点样式 + 文档点击跳转编辑器

- 思维导图: pill/chip 样式节点, 类型着色(root/folder/doc), 图标, 子节点计数徽标
- 知识图谱: 卡片圆角+投影+类型图标+星标, 悬停高亮
- 点击文档节点(思维导图/图谱)切换到编辑器界面
- 新增思维导图跳转 E2E 测试
This commit is contained in:
hunter-dev
2026-09-09 20:29:36 +08:00
parent dc8d844a1f
commit d0dbdfd00a
3 changed files with 118 additions and 41 deletions
@@ -8,7 +8,7 @@ import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
// M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线
type SimNode = GraphNode & SimulationNodeDatum;
const CARD_W = 170, CARD_H = 46;
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 }) {
@@ -37,6 +37,10 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
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.28);
const g = svg.append('g');
// FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击
@@ -48,43 +52,56 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
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) * 220,
y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 220,
x: W / 2 + Math.cos((2 * Math.PI * i) / n) * 230,
y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 230,
}));
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.6)
.selectAll('line').data(links).join('line').attr('stroke-width', 1);
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 sim = forceSimulation<SimNode>(nodes)
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(130).strength(0.2))
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(140).strength(0.2))
.force('charge', forceManyBody().strength(-360))
.force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(95));
.force('collide', forceCollide(105));
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer');
// FR-G1:卡片:外框 + 色条 + 文档图标 + 标题 + 库内路径副文本 + 星标
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 5)
.attr('fill', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').attr('stroke-width', 1.5);
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', 4).attr('height', CARD_H).attr('fill', (d) => d.color || '#5db0ff');
// FR-G1:文档图标
nodeSel.append('text').attr('x', -CARD_W / 2 + 12).attr('y', 0).attr('font-size', 12).attr('fill', 'var(--text-tertiary)').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', 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 + 28).attr('y', -2).attr('fill', 'var(--text-primary)').attr('font-size', 12).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 14));
// FR-G1:库内路径副文本
nodeSel.append('text').attr('x', -CARD_W / 2 + 28).attr('y', 12).attr('fill', 'var(--text-tertiary)').attr('font-size', 9).attr('font-family', 'sans-serif')
.text((d) => truncate(d.basePath || d.id, 18));
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', 2).attr('fill', 'var(--accent)').attr('font-size', 12).text('*');
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', '#f5c518').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; }');
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:点击卡片打开文档并切回编辑器视图
// d.id 是文档路径(如 'g5doc.md'),d.basePath 是所在目录(根目录为空)
nodeSel.attr('data-doc-path', (d: any) => d.id || '');
nodeSel.each(function(d: any) {
const el = this as HTMLElement;
el.addEventListener('click', (ev: MouseEvent) => {
@@ -124,7 +141,6 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
return (
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<svg data-testid="graph-canvas" ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
{/* FR-G4:画布控件 */}
<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>
@@ -136,4 +152,4 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
);
}
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 };
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 };