- 后端 Gateway 挂载 /api/knowledge-ingest 路由(发布 md 制品) - 知识图谱力模拟按节点规模缩放斥力/截断距离/冷却速率,避免数百节点时布局长时间不收敛 - 斥力与碰撞改为基于均匀网格的近邻对计算,并增加每帧最大位移限制防止抖动与飞出 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
import type {
|
|
EdgeInfo,
|
|
GraphEdge,
|
|
GraphNode,
|
|
GraphStats,
|
|
NodeKind,
|
|
SimEdge,
|
|
SimNode,
|
|
} from "./graphTypes";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const getNodeTitle = (node: GraphNode): string =>
|
|
node.title || node.label || node.name || node.id;
|
|
|
|
const KIND_PATTERNS: Array<[NodeKind, RegExp]> = [
|
|
["person", /^(person|people|human|人物|人名|人员)$/i],
|
|
["org", /^(org|organization|organisation|company|institution|party|组织|机构|公司|团体|政党|国会)$/i],
|
|
["location", /^(location|place|country|city|region|gpe|地点|地区|国家|城市|地理位置)$/i],
|
|
["event", /^(event|事件|活动|会议|战争|选举|讲话)$/i],
|
|
["tag", /^(tag|keyword|标签|关键词)$/i],
|
|
["concept", /^(concept|topic|term|technology|theory|product|project|主题|概念|术语|技术|理论|产品|项目|政策)$/i],
|
|
];
|
|
|
|
/** Map a raw node (note source_type / entity entity_type) to a visual bucket. */
|
|
export function getNodeKind(node: GraphNode): NodeKind {
|
|
const type = (node.type || "").toLowerCase();
|
|
if (type === "note" || type === "document" || type === "file") return "document";
|
|
const category = (node.category || "").trim();
|
|
if (category) {
|
|
for (const [kind, re] of KIND_PATTERNS) {
|
|
if (re.test(category)) return kind;
|
|
}
|
|
}
|
|
// Unmatched LLM entity types still read better as "concept" than as a grey blob.
|
|
if (type === "entity") return category ? "other" : "concept";
|
|
return "other";
|
|
}
|
|
|
|
export const NODE_KIND_META: Record<NodeKind, { label: string; color: string }> = {
|
|
document: { label: "文档", color: "#38bdf8" },
|
|
concept: { label: "主题", color: "#a78bfa" },
|
|
person: { label: "人物", color: "#fbbf24" },
|
|
org: { label: "组织", color: "#2dd4bf" },
|
|
location: { label: "地点", color: "#34d399" },
|
|
event: { label: "事件", color: "#fb7185" },
|
|
tag: { label: "标签", color: "#a3e635" },
|
|
other: { label: "其他", color: "#94a3b8" },
|
|
};
|
|
|
|
/** Stable ordering for filters / legend. */
|
|
export const NODE_KIND_ORDER: NodeKind[] = [
|
|
"document",
|
|
"concept",
|
|
"person",
|
|
"org",
|
|
"location",
|
|
"event",
|
|
"tag",
|
|
"other",
|
|
];
|
|
|
|
export const getNodeRadius = (degree: number): number =>
|
|
Math.min(30, 9 + Math.sqrt(Math.max(degree, 0)) * 4);
|
|
|
|
export function truncateLabel(text: string, max = 14): string {
|
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edge helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const getEdgeLabel = (edge: GraphEdge): string =>
|
|
edge.label || edge.type || edge.relation || "related";
|
|
|
|
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
|
|
export const getEdgeId = (edge: GraphEdge): string =>
|
|
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
|
|
|
|
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
|
|
related: { color: "#94a3b8", zh: "相关", dashed: false },
|
|
links: { color: "#a78bfa", zh: "引用", dashed: true },
|
|
references: { color: "#a78bfa", zh: "引用", dashed: true },
|
|
contains: { color: "#34d399", zh: "包含", dashed: false },
|
|
belongs_to: { color: "#2dd4bf", zh: "属于", dashed: false },
|
|
similar: { color: "#fbbf24", zh: "相似", dashed: true },
|
|
mentions: { color: "#38bdf8", zh: "提及", dashed: false },
|
|
};
|
|
|
|
const EDGE_PALETTE = [
|
|
"#38bdf8",
|
|
"#a78bfa",
|
|
"#34d399",
|
|
"#fbbf24",
|
|
"#fb7185",
|
|
"#2dd4bf",
|
|
"#fb923c",
|
|
"#e879f9",
|
|
];
|
|
|
|
function hashString(text: string): number {
|
|
let h = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
h = (h * 31 + text.charCodeAt(i)) | 0;
|
|
}
|
|
return Math.abs(h);
|
|
}
|
|
|
|
/** Color / display name / line style of an edge label (stable for unknown labels). */
|
|
export function getEdgeInfo(label: string): EdgeInfo {
|
|
const known = KNOWN_EDGE_INFO[label.toLowerCase()];
|
|
if (known) return known;
|
|
return {
|
|
color: EDGE_PALETTE[hashString(label) % EDGE_PALETTE.length],
|
|
zh: label,
|
|
dashed: false,
|
|
};
|
|
}
|
|
|
|
export function getLinkDistance(label: string): number {
|
|
switch (label.toLowerCase()) {
|
|
case "contains":
|
|
return 100;
|
|
case "links":
|
|
case "references":
|
|
return 130;
|
|
case "similar":
|
|
return 170;
|
|
default:
|
|
return 145;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Graph statistics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function computeDegrees(
|
|
nodes: GraphNode[],
|
|
edges: GraphEdge[],
|
|
): Map<string, number> {
|
|
const degrees = new Map<string, number>();
|
|
for (const n of nodes) degrees.set(n.id, 0);
|
|
for (const e of edges) {
|
|
if (degrees.has(e.source)) degrees.set(e.source, (degrees.get(e.source) ?? 0) + 1);
|
|
if (degrees.has(e.target)) degrees.set(e.target, (degrees.get(e.target) ?? 0) + 1);
|
|
}
|
|
return degrees;
|
|
}
|
|
|
|
export function computeGraphStats(
|
|
nodes: GraphNode[],
|
|
edges: GraphEdge[],
|
|
): GraphStats {
|
|
const adjacency = new Map<string, string[]>();
|
|
for (const n of nodes) adjacency.set(n.id, []);
|
|
let validEdges = 0;
|
|
for (const e of edges) {
|
|
if (!adjacency.has(e.source) || !adjacency.has(e.target)) continue;
|
|
validEdges += 1;
|
|
adjacency.get(e.source)!.push(e.target);
|
|
adjacency.get(e.target)!.push(e.source);
|
|
}
|
|
// Largest connected component via BFS.
|
|
const visited = new Set<string>();
|
|
let largest = 0;
|
|
for (const n of nodes) {
|
|
if (visited.has(n.id)) continue;
|
|
let size = 0;
|
|
const queue = [n.id];
|
|
visited.add(n.id);
|
|
while (queue.length) {
|
|
const cur = queue.pop()!;
|
|
size += 1;
|
|
for (const next of adjacency.get(cur) ?? []) {
|
|
if (!visited.has(next)) {
|
|
visited.add(next);
|
|
queue.push(next);
|
|
}
|
|
}
|
|
}
|
|
largest = Math.max(largest, size);
|
|
}
|
|
return {
|
|
nodes: nodes.length,
|
|
edges: validEdges,
|
|
avgDegree: nodes.length ? (validEdges * 2) / nodes.length : 0,
|
|
largestComponent: largest,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layouts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const GOLDEN_ANGLE = 2.3999632297286533;
|
|
|
|
/** Phyllotaxis seeding around the origin — hubs (high degree) near the center. */
|
|
export function seedPositions(nodes: SimNode[]): void {
|
|
const sorted = [...nodes].sort((a, b) => b.degree - a.degree);
|
|
sorted.forEach((n, i) => {
|
|
const r = 22 * Math.sqrt(i + 0.5);
|
|
const angle = i * GOLDEN_ANGLE;
|
|
n.x = r * Math.cos(angle);
|
|
n.y = r * Math.sin(angle);
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
n.fx = null;
|
|
n.fy = null;
|
|
});
|
|
}
|
|
|
|
/** Static circular layout, grouped by kind then degree. */
|
|
export function applyCircularLayout(nodes: SimNode[]): void {
|
|
const sorted = [...nodes].sort((a, b) => {
|
|
const ka = NODE_KIND_ORDER.indexOf(a.kind);
|
|
const kb = NODE_KIND_ORDER.indexOf(b.kind);
|
|
return ka !== kb ? ka - kb : b.degree - a.degree;
|
|
});
|
|
const radius = Math.max(180, (sorted.length * 56) / (2 * Math.PI));
|
|
sorted.forEach((n, i) => {
|
|
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
|
|
n.x = radius * Math.cos(angle);
|
|
n.y = radius * Math.sin(angle);
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
n.fx = null;
|
|
n.fy = null;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Force simulation (d3-force-like, dependency-free, fine for <500 nodes)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class ForceSimulation {
|
|
alpha = 1;
|
|
readonly alphaMin = 0.004;
|
|
private readonly alphaDecay: number;
|
|
private readonly velocityDecay = 0.38;
|
|
private readonly chargeStrength: number;
|
|
private readonly chargeCutoff: number;
|
|
private readonly chargeCutoff2: number;
|
|
/** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */
|
|
private readonly maxSpeed = 30;
|
|
|
|
constructor(
|
|
public nodes: SimNode[],
|
|
public edges: SimEdge[],
|
|
) {
|
|
// Scale forces down and cool faster as the graph grows — with the
|
|
// small-graph constants a few hundred nodes keep shoving each other
|
|
// around for minutes and the layout never settles.
|
|
const n = nodes.length;
|
|
if (n > 600) {
|
|
this.chargeStrength = -160;
|
|
this.chargeCutoff = 260;
|
|
this.alphaDecay = 0.05;
|
|
} else if (n > 300) {
|
|
this.chargeStrength = -240;
|
|
this.chargeCutoff = 340;
|
|
this.alphaDecay = 0.038;
|
|
} else if (n > 150) {
|
|
this.chargeStrength = -320;
|
|
this.chargeCutoff = 440;
|
|
this.alphaDecay = 0.03;
|
|
} else {
|
|
this.chargeStrength = -420;
|
|
this.chargeCutoff = 600;
|
|
this.alphaDecay = 0.0228;
|
|
}
|
|
this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff;
|
|
}
|
|
|
|
reheat(alpha = 0.9): void {
|
|
this.alpha = Math.max(this.alpha, alpha);
|
|
}
|
|
|
|
/** Advance one step; returns false when the layout has cooled down. */
|
|
tick(): boolean {
|
|
if (this.alpha < this.alphaMin) return false;
|
|
this.alpha += (0 - this.alpha) * this.alphaDecay;
|
|
const a = this.alpha;
|
|
const { nodes, edges } = this;
|
|
|
|
// Link spring force.
|
|
for (const e of edges) {
|
|
const s = e.source;
|
|
const t = e.target;
|
|
let dx = t.x - s.x;
|
|
let dy = t.y - s.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1e-3;
|
|
const f = ((dist - e.distance) / dist) * 0.45 * a;
|
|
dx *= f;
|
|
dy *= f;
|
|
t.vx -= dx * 0.5;
|
|
t.vy -= dy * 0.5;
|
|
s.vx += dx * 0.5;
|
|
s.vy += dy * 0.5;
|
|
}
|
|
|
|
// Repulsion + collision over nearby pairs only (uniform grid).
|
|
this.applyPairForces(a);
|
|
|
|
// Gentle pull toward the origin keeps disconnected clusters on screen.
|
|
for (const n of nodes) {
|
|
n.vx += (0 - n.x) * 0.05 * a;
|
|
n.vy += (0 - n.y) * 0.05 * a;
|
|
}
|
|
|
|
// Integrate velocities.
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const n = nodes[i];
|
|
if (n.fx != null && n.fy != null) {
|
|
n.x = n.fx;
|
|
n.y = n.fy;
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
} else {
|
|
n.vx *= 1 - this.velocityDecay;
|
|
n.vy *= 1 - this.velocityDecay;
|
|
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
|
if (speed > this.maxSpeed) {
|
|
const s = this.maxSpeed / speed;
|
|
n.vx *= s;
|
|
n.vy *= s;
|
|
}
|
|
n.x += n.vx;
|
|
n.y += n.vy;
|
|
}
|
|
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
|
|
// A NaN/Infinity coordinate would blank the whole canvas (the
|
|
// bounding box and fit transform become NaN); park the node instead.
|
|
const r = 40 + 18 * Math.sqrt(i + 1);
|
|
n.x = r * Math.cos(i * GOLDEN_ANGLE);
|
|
n.y = r * Math.sin(i * GOLDEN_ANGLE);
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Many-body repulsion + collision resolution, restricted to pairs within
|
|
* one grid cell (cell size = charge cutoff). Two nodes farther apart than
|
|
* the cutoff never interact, so binning by cell keeps the scan close to
|
|
* O(n) for spread-out layouts instead of O(n²) over all pairs.
|
|
*/
|
|
private applyPairForces(a: number): void {
|
|
const cellSize = this.chargeCutoff;
|
|
const grid = new Map<string, { cx: number; cy: number; items: SimNode[] }>();
|
|
for (const n of this.nodes) {
|
|
const cx = Math.floor(n.x / cellSize);
|
|
const cy = Math.floor(n.y / cellSize);
|
|
const key = `${cx},${cy}`;
|
|
let bucket = grid.get(key);
|
|
if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] }));
|
|
bucket.items.push(n);
|
|
}
|
|
// Half of the 8-neighborhood so every cell pair is visited exactly once.
|
|
const offsets = [
|
|
[0, 0],
|
|
[1, 0],
|
|
[0, 1],
|
|
[1, 1],
|
|
[1, -1],
|
|
] as const;
|
|
for (const bucket of grid.values()) {
|
|
for (const [ox, oy] of offsets) {
|
|
const other =
|
|
ox === 0 && oy === 0
|
|
? bucket
|
|
: grid.get(`${bucket.cx + ox},${bucket.cy + oy}`);
|
|
if (!other) continue;
|
|
const left = bucket.items;
|
|
const right = other.items;
|
|
if (other === bucket) {
|
|
for (let i = 0; i < left.length; i++) {
|
|
for (let j = i + 1; j < left.length; j++) {
|
|
this.pairForce(left[i], left[j], i, j, a);
|
|
}
|
|
}
|
|
} else {
|
|
for (let i = 0; i < left.length; i++) {
|
|
for (let j = 0; j < right.length; j++) {
|
|
this.pairForce(left[i], right[j], i, j, a);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void {
|
|
let dx = nj.x - ni.x;
|
|
let dy = nj.y - ni.y;
|
|
let d2 = dx * dx + dy * dy;
|
|
if (d2 > this.chargeCutoff2) return;
|
|
if (d2 < 1) {
|
|
// Deterministic nudge for coincident nodes.
|
|
dx = ((i % 3) - 1) * 0.5 || 0.5;
|
|
dy = ((j % 3) - 1) * 0.5 || -0.5;
|
|
d2 = dx * dx + dy * dy;
|
|
}
|
|
const d = Math.sqrt(d2);
|
|
const f = (this.chargeStrength * a) / d2;
|
|
const fx = (dx / d) * f;
|
|
const fy = (dy / d) * f;
|
|
ni.vx += fx;
|
|
ni.vy += fy;
|
|
nj.vx -= fx;
|
|
nj.vy -= fy;
|
|
|
|
// Positional collision resolution (prevents node overlap).
|
|
const minDist = ni.radius + nj.radius + 8;
|
|
if (d < minDist) {
|
|
const push = ((minDist - d) / d) * 0.5;
|
|
const px = dx * push;
|
|
const py = dy * push;
|
|
if (ni.fx == null) {
|
|
ni.x -= px;
|
|
ni.y -= py;
|
|
}
|
|
if (nj.fx == null) {
|
|
nj.x += px;
|
|
nj.y += py;
|
|
}
|
|
}
|
|
}
|
|
}
|