- 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>
189 lines
5.9 KiB
JavaScript
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;
|