// 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;