feat(kb): 知识图谱换 react-force-graph-2d(活物理 + 拖拽 + 缩放)

把自建静态 SVG 力导向换成 react-force-graph-2d(canvas + d3-force)——真实物理、
可拖拽节点、滚轮缩放、悬停高亮邻域。实体图谱与笔记关系图同一组件,全部升级。

- GraphView 重写:ForceGraph2D,nodeCanvasObject 自绘节点(按度着色/缩放)+标签,
  linkCanvasObject 放大后显示关系文字,onNodeHover 高亮邻域、onNodeClick→onNode(笔记图跳转)。
- 调力:charge=-180 / link distance=52 拉开布局;onEngineStop zoomToFit 自动取景。
- 保持原 props(triples/height/onNode),三处调用零改动。

验证(Preview):笔记关系图渲染 笔记B/项目A概述/模块X 大节点 + 链接边、自动取景、可拖拽缩放。
(实体图谱在 default 库因累积了几十个测试实体显得密,新建干净库则清爽。)tsc+vite 通过;重建 .app。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 15:55:34 +08:00
parent 10ac5a5277
commit a222ca5f9e
3 changed files with 419 additions and 150 deletions
@@ -1,171 +1,130 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import ForceGraph2D from "react-force-graph-2d";
import { Network } from "lucide-react";
import type { Triple } from "../lib/api";
import { EmptyState } from "../ui";
interface GNode {
id: string;
x: number;
y: number;
deg: number;
}
interface GEdge {
s: string;
o: string;
p: string;
// 节点按度着色:枢纽紫 / 关联青 / 叶子灰。
function nodeColor(deg: number): string {
if (deg >= 4) return "#8b5cf6";
if (deg >= 2) return "#22d3ee";
return "#64748b";
}
// layout 用一个轻量力导向模拟(斥力 + 边弹簧 + 居中)把三元组排成图。
// 静态收敛(useMemo 内跑固定迭代),零依赖;节点过多时按度裁剪。
function layout(triples: Triple[], W: number, H: number): { nodes: GNode[]; edges: GEdge[] } {
function build(triples: Triple[]) {
const deg = new Map<string, number>();
for (const t of triples) {
if (!t.s || !t.o) continue;
deg.set(t.s, (deg.get(t.s) ?? 0) + 1);
deg.set(t.o, (deg.get(t.o) ?? 0) + 1);
}
// 裁剪:实体过多只留度最高的 N 个,保留两端都在集合内的边。
let names = [...deg.keys()];
const CAP = 60;
if (names.length > CAP) {
names = names.sort((a, b) => (deg.get(b)! - deg.get(a)!)).slice(0, CAP);
}
const keep = new Set(names);
const edges = triples.filter((t) => keep.has(t.s) && keep.has(t.o)).map((t) => ({ s: t.s, o: t.o, p: t.p }));
const nodes = new Map<string, GNode>();
const R = Math.min(W, H) * 0.36;
names.forEach((n, i) => {
const a = (2 * Math.PI * i) / names.length;
// 初始撒在圆周上(确定性,避免每次重排抖动)。
nodes.set(n, { id: n, x: W / 2 + Math.cos(a) * R, y: H / 2 + Math.sin(a) * R, deg: deg.get(n)! });
});
const arr = [...nodes.values()];
for (let it = 0; it < 320; it++) {
// 斥力(库仑)
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
const a = arr[i],
b = arr[j];
let dx = a.x - b.x,
dy = a.y - b.y;
let d2 = dx * dx + dy * dy;
if (d2 < 1) {
d2 = 1;
dx = 1;
}
const d = Math.sqrt(d2);
const f = 2600 / d2;
a.x += (dx / d) * f;
a.y += (dy / d) * f;
b.x -= (dx / d) * f;
b.y -= (dy / d) * f;
}
}
// 边弹簧(理想长度 ~96
for (const e of edges) {
const a = nodes.get(e.s)!,
b = nodes.get(e.o)!;
const dx = b.x - a.x,
dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const f = (d - 96) * 0.012;
a.x += (dx / d) * f;
a.y += (dy / d) * f;
b.x -= (dx / d) * f;
b.y -= (dy / d) * f;
}
// 轻微居中 + 边界约束
for (const a of arr) {
a.x += (W / 2 - a.x) * 0.004;
a.y += (H / 2 - a.y) * 0.004;
a.x = Math.max(20, Math.min(W - 20, a.x));
a.y = Math.max(16, Math.min(H - 16, a.y));
}
}
return { nodes: arr, edges };
const nodes = [...deg.keys()].map((id) => ({ id, deg: deg.get(id)! }));
const links = triples.filter((t) => t.s && t.o).map((t) => ({ source: t.s, target: t.o, label: t.p }));
return { nodes, links };
}
function nodeColor(deg: number): { fill: string; text: string } {
if (deg >= 4) return { fill: "#8b5cf6", text: "#ede9fe" }; // 枢纽:brand
if (deg >= 2) return { fill: "#22d3ee", text: "#083344" }; // 次枢纽:accent
return { fill: "#1a1f2d", text: "#cbd5e1" }; // 叶子
}
// GraphView 把知识三元组渲染为力导向图(实体=节点,关系=带标签的边),hover 高亮邻域。
// onNode 非空时节点可点(用于笔记关系图点节点开笔记)。
// GraphView 用 react-force-graph-2d 渲染知识三元组:活物理力导向、可拖拽、滚轮缩放、悬停高亮。
// 实体=节点(按度着色/缩放),关系=带标签边;onNode 非空时点节点回调(笔记关系图用)。
export function GraphView({ triples, height = 360, onNode }: { triples: Triple[]; height?: number; onNode?: (id: string) => void }) {
const W = 560;
const H = height;
const wrapRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<any>(null);
const [width, setWidth] = useState(600);
const [hover, setHover] = useState<string | null>(null);
const { nodes, edges } = useMemo(() => layout(triples, W, H), [triples, H]);
const pos = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setWidth(el.clientWidth));
ro.observe(el);
setWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
const data = useMemo(() => build(triples), [triples]);
// 加强斥力 + 拉开边距,避免节点挤成一团(d3 默认 charge=-30 对稍大的图偏弱)。
useEffect(() => {
const fg = fgRef.current;
if (!fg) return;
fg.d3Force("charge")?.strength(-180);
fg.d3Force("link")?.distance(52);
fg.d3ReheatSimulation?.();
}, [data]);
// 悬停某节点时,与之相邻的节点/边高亮,其余淡出。
const neighbors = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const l of data.links) {
if (!map.has(l.source)) map.set(l.source, new Set());
if (!map.has(l.target)) map.set(l.target, new Set());
map.get(l.source)!.add(l.target);
map.get(l.target)!.add(l.source);
}
return map;
}, [data]);
if (triples.length === 0) {
return <EmptyState icon={Network} title="暂无图谱" desc="入库文本后,LLM 会抽取实体与关系,这里渲染为可交互的知识图谱。" />;
return <EmptyState icon={Network} title="暂无图谱" desc="入库文本后,LLM 会抽取实体与关系;笔记互相 [[链接]] 也会连成图。" />;
}
const neighbors = (id: string) => {
const s = new Set<string>([id]);
for (const e of edges) {
if (e.s === id) s.add(e.o);
if (e.o === id) s.add(e.s);
}
return s;
};
const active = hover ? neighbors(hover) : null;
const nodeOn = (id: string) => !active || active.has(id);
const edgeOn = (e: GEdge) => !hover || e.s === hover || e.o === hover;
const dim = (id: string) => hover !== null && hover !== id && !(neighbors.get(hover)?.has(id) ?? false);
return (
<div className="flex flex-col gap-2">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full rounded-md border border-line bg-ink-950/60" style={{ height }}>
{edges.map((e, i) => {
const a = pos.get(e.s)!,
b = pos.get(e.o)!;
if (!a || !b) return null;
const on = edgeOn(e);
const mx = (a.x + b.x) / 2,
my = (a.y + b.y) / 2;
return (
<g key={i} opacity={on ? 1 : 0.12}>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#39435a" strokeWidth={1} />
{on && (
<text x={mx} y={my - 2} fill="#7c8aa5" fontSize={8.5} textAnchor="middle">
{e.p}
</text>
)}
</g>
);
})}
{nodes.map((n) => {
const c = nodeColor(n.deg);
const r = Math.min(7 + n.deg * 1.6, 16);
const on = nodeOn(n.id);
return (
<g
key={n.id}
opacity={on ? 1 : 0.2}
onMouseEnter={() => setHover(n.id)}
onMouseLeave={() => setHover(null)}
onClick={() => onNode?.(n.id)}
style={{ cursor: "pointer" }}
>
<circle cx={n.x} cy={n.y} r={r} fill={c.fill} stroke={hover === n.id ? "#fff" : "#0b0d12"} strokeWidth={hover === n.id ? 2 : 1.5} />
<text x={n.x} y={n.y + r + 9} fill="#cbd5e1" fontSize={9.5} textAnchor="middle">
{n.id.length > 10 ? n.id.slice(0, 10) + "…" : n.id}
</text>
</g>
);
})}
</svg>
<div className="flex items-center gap-3 px-1 text-[10px] text-slate-500">
<span>{nodes.length} · {edges.length} </span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: "#8b5cf6" }} /> </span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: "#22d3ee" }} /> </span>
<span className="ml-auto"></span>
</div>
<div ref={wrapRef} className="overflow-hidden rounded-md border border-line bg-ink-950/60" style={{ height }}>
<ForceGraph2D
ref={fgRef}
graphData={data}
width={width}
height={height}
backgroundColor="rgba(0,0,0,0)"
cooldownTicks={120}
onEngineStop={() => fgRef.current?.zoomToFit(400, 50)}
nodeRelSize={5}
nodeVal={(n: any) => 1 + (n.deg || 0)}
nodeLabel={(n: any) => `${n.id}(度 ${n.deg}`}
onNodeHover={(n: any) => setHover(n ? n.id : null)}
onNodeClick={(n: any) => onNode?.(n.id)}
linkColor={(l: any) => (hover && l.source.id !== hover && l.target.id !== hover ? "rgba(57,67,90,0.25)" : "#39435a")}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
linkLabel={(l: any) => l.label}
nodeCanvasObjectMode={() => "replace"}
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, scale: number) => {
const r = Math.max(3, 4 + (node.deg || 0) * 0.9);
const faded = dim(node.id);
ctx.globalAlpha = faded ? 0.2 : 1;
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
ctx.fillStyle = nodeColor(node.deg);
ctx.fill();
if (hover === node.id) {
ctx.lineWidth = 1.5 / scale;
ctx.strokeStyle = "#fff";
ctx.stroke();
}
const fs = 10 / scale;
ctx.font = `${fs}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillStyle = faded ? "rgba(203,213,225,0.25)" : "#cbd5e1";
const label = node.id.length > 12 ? node.id.slice(0, 12) + "…" : node.id;
ctx.fillText(label, node.x, node.y + r + 1);
ctx.globalAlpha = 1;
}}
linkCanvasObjectMode={() => "after"}
linkCanvasObject={(link: any, ctx: CanvasRenderingContext2D, scale: number) => {
if (scale < 1.2 || !link.label) return; // 放大到一定程度才显示关系文字,避免拥挤
const s = link.source,
t = link.target;
if (!s || !t) return;
const mx = (s.x + t.x) / 2,
my = (s.y + t.y) / 2;
ctx.font = `${8 / scale}px sans-serif`;
ctx.fillStyle = "#7c8aa5";
ctx.textAlign = "center";
ctx.fillText(link.label, mx, my);
}}
/>
</div>
);
}