Files
PlantAI-rag-new/frontend/src/components/bench/graphLayout.js
T
jerryandClaude Fable 5 1e94de7464 PlantAI-rag-new 首次提交:文献知识工作台
- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:47:52 +08:00

189 lines
5.9 KiB
JavaScript

// bench/graphLayout.js —— 手写 SVG 力导向布局(无第三方图库;B3 归属)
// DESIGN.md 禁 CDN,图谱由纯手写算法完成:圆形种子初始化 → 有限迭代斥力/弹簧 → 包围盒归一化。
// 输入/输出均为与 Vue 无关的纯数据,便于单元测试。
/**
* 节点类型 → 调色板稳定映射(FR-KG-002:品牌色 + 辅助紫 + 五相位色)。
* 未知类型归入 'unknown'。注意顺序固定:保证同一 type 会话内颜色稳定。
*/
const PALETTE = [
'#5E6AD2', // 品牌 g-from
'#00D4FF', // 品牌 g-to
'#A78BFA', // 辅助紫
'#C0A8DD', // 相位 规划
'#9FBBE0', // 相位 检索
'#9FC9A2', // 相位 证据
'#DFA88F', // 相位 生成
'#C08532', // 相位 完成
];
/**
* 对一组 type 做稳定配色(FR-KG-002)。
* 调用方可预传入稳定基数 base=1,避免不同图之间同一 type 撞不同颜色。
*/
export function buildPalette(types, base = 1) {
const seen = new Set();
const sorted = [...types].sort();
for (const t of sorted) if (t) seen.add(t);
const map = new Map();
let i = 0;
for (const t of [...seen, 'unknown']) {
if (!map.has(t)) map.set(t, PALETTE[(base + i) % PALETTE.length]);
i += 1;
}
return map;
}
/** 稳定索引(字符串哈希),供确定着色 */
export function hashString(s) {
let h = 0;
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
/**
* 力导向布局主入口。
* @param {{nodes:Array<{id,label?,type?}>, edges:Array<{source,target,relation?}>}} graph
* @param {{width?:number, height?:number, seed?:number, iterations?:number}} opts
* @returns {Array<{id,label,type,x,y,vx,vy}>}
*/
export function forceLayout(graph, { width = 800, height = 600, seed = 0, iterations = 180 } = {}) {
const nodes = (graph?.nodes || [])
.filter((n) => n && n.id != null)
.map((n) => ({
id: String(n.id),
label: n.label != null ? String(n.label) : String(n.id),
type: n.type || 'unknown',
x: 0,
y: 0,
vx: 0,
vy: 0,
}));
const ids = new Set(nodes.map((n) => n.id));
const edges = (graph?.edges || [])
.filter((e) => e && ids.has(String(e.source)) && ids.has(String(e.target)))
.map((e) => ({ source: String(e.source), target: String(e.target), relation: e.relation || e.label || '' }));
if (!nodes.length) return { nodes: [], edges: [], width, height };
const n = nodes.length;
const cx = width / 2;
const cy = height / 2;
// 圆形种子初始化(确定性,不依赖 Math.random 顺序)
for (let i = 0; i < n; i += 1) {
const ang = (i / n) * Math.PI * 2 + seed * 0.13;
nodes[i].x = cx + Math.cos(ang) * 120;
nodes[i].y = cy + Math.sin(ang) * 120;
nodes[i].vx = 0;
nodes[i].vy = 0;
}
// 邻接表(用于跳过相邻节点间的斥力,避免边把邻居完全推开)
const adj = new Map();
for (const nd of nodes) adj.set(nd.id, new Set());
for (const e of edges) {
adj.get(e.source)?.add(e.target);
adj.get(e.target)?.add(e.source);
}
const k = Math.sqrt((width * height) / Math.max(n, 1)) * 0.9;
const iters = Math.max(60, Math.min(iterations, 300));
for (let it = 0; it < iters; it += 1) {
const cooling = 1 - it / iters; // 温度线性退火
// 斥力(库仑):相邻节点跳过,减少抖动
for (let i = 0; i < n; i += 1) {
for (let j = i + 1; j < n; j += 1) {
const a = nodes[i];
const b = nodes[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
let d2 = dx * dx + dy * dy;
if (d2 < 1) {
dx = (hashString(a.id + b.id) % 100) - 50;
dy = (hashString(b.id + a.id) % 100) - 50;
d2 = dx * dx + dy * dy + 1;
}
if (adj.get(a.id).has(b.id)) continue;
const d = Math.sqrt(d2);
const force = (k * k) / d2 * 1.2;
const fx = (dx / d) * force;
const fy = (dy / d) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
// 弹簧(胡克):边相连节点保持理想距离
for (const e of edges) {
const a = nodes.find((nd) => nd.id === e.source);
const b = nodes.find((nd) => nd.id === e.target);
if (!a || !b) continue;
let dx = b.x - a.x;
let dy = b.y - a.y;
let d = Math.sqrt(dx * dx + dy * dy) || 1;
const target = k * 0.7;
const force = (d - target) * 0.04;
const fx = (dx / d) * force;
const fy = (dy / d) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
// 中心引力
for (const nd of nodes) {
nd.vx += (cx - nd.x) * 0.004;
nd.vy += (cy - nd.y) * 0.004;
}
// 积分 + 阻尼 + 限速
for (const nd of nodes) {
nd.vx *= 0.55;
nd.vy *= 0.55;
const sp = Math.hypot(nd.vx, nd.vy);
const maxV = k * 0.3;
if (sp > maxV) {
nd.vx = (nd.vx / sp) * maxV;
nd.vy = (nd.vy / sp) * maxV;
}
nd.x += nd.vx * cooling + nd.vx * 0.4;
nd.y += nd.vy * cooling + nd.vy * 0.4;
}
}
// 包围盒归一化:平移到 (0,0),缩放适配画布,留 40px 标签边距
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const nd of nodes) {
minX = Math.min(minX, nd.x);
minY = Math.min(minY, nd.y);
maxX = Math.max(maxX, nd.x);
maxY = Math.max(maxY, nd.y);
}
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const pad = 40;
const sx = (width - pad * 2) / bw;
const sy = (height - pad * 2) / bh;
const s = Math.min(sx, sy, 1.6);
const offX = (width - bw * s) / 2;
const offY = (height - bh * s) / 2;
for (const nd of nodes) {
nd.x = (nd.x - minX) * s + offX;
nd.y = (nd.y - minY) * s + offY;
}
return { nodes, edges, width, height };
}
/** 每边最多渲染的关系名标签数(防 hub 拥挤) */
export const MAX_EDGE_LABELS = 12;