Skip to main content
Glama
SvgService.ts8.49 kB
import { Graph, Node, Edge, NodeType } from '../models/Graph'; import { KnowledgeGraphConfig } from '../config/types'; export class SvgService { private settings: KnowledgeGraphConfig['svgSettings']; constructor(settings?: KnowledgeGraphConfig['svgSettings']) { this.settings = settings || { width: 1200, height: 800, nodeRadius: 20, fontSize: 12, fontFamily: 'Arial', colors: { component: '#4285F4', module: '#EA4335', service: '#FBBC05', data: '#34A853', api: '#8E44AD', concept: '#E67E22', resource: '#16A085', default: '#95A5A6' } }; } public generateGraphSvg(graph: Graph): string { const { width, height, nodeRadius, fontSize, fontFamily, colors } = this.settings; const nodes = graph.nodes; const edges = graph.edges; // 计算节点位置 const positions = this.calculateNodePositions(nodes, edges, width, height); // 生成SVG let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> <defs> <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"> <polygon points="0 0, 10 3.5, 0 7" /> </marker> </defs> <style> .node { cursor: pointer; } .node:hover { opacity: 0.8; } .node-text { font-family: ${fontFamily}; font-size: ${fontSize}px; fill: #fff; text-anchor: middle; } .edge { stroke-width: 2; stroke: #999; marker-end: url(#arrowhead); } .edge:hover { stroke-width: 3; stroke: #666; } .edge-label { font-family: ${fontFamily}; font-size: ${fontSize - 2}px; fill: #666; text-anchor: middle; } </style> <rect width="100%" height="100%" fill="#f9f9f9" /> <g class="graph">`; // 绘制边 edges.forEach(edge => { const sourcePos = positions[edge.source]; const targetPos = positions[edge.target]; if (sourcePos && targetPos) { // 计算连接点 const angle = Math.atan2(targetPos.y - sourcePos.y, targetPos.x - sourcePos.x); const sourceX = sourcePos.x + Math.cos(angle) * nodeRadius; const sourceY = sourcePos.y + Math.sin(angle) * nodeRadius; const targetX = targetPos.x - Math.cos(angle) * (nodeRadius + 5); // 5px为箭头偏移 const targetY = targetPos.y - Math.sin(angle) * (nodeRadius + 5); svgContent += ` <line class="edge" x1="${sourceX}" y1="${sourceY}" x2="${targetX}" y2="${targetY}" data-type="${edge.type}" data-id="${edge.id}" />`; // 如果边有标签,添加标签 if (edge.label) { const midX = (sourceX + targetX) / 2; const midY = (sourceY + targetY) / 2; const labelOffsetX = Math.sin(angle) * 15; // 标签偏移量 const labelOffsetY = -Math.cos(angle) * 15; svgContent += ` <text class="edge-label" x="${midX + labelOffsetX}" y="${midY + labelOffsetY}">${edge.label}</text>`; } } }); // 绘制节点 nodes.forEach(node => { const pos = positions[node.id]; if (pos) { const nodeColor = this.getNodeColor(node.type, colors); svgContent += ` <g class="node" data-id="${node.id}" data-type="${node.type}"> <circle cx="${pos.x}" cy="${pos.y}" r="${nodeRadius}" fill="${nodeColor}" /> <text class="node-text" x="${pos.x}" y="${pos.y + 5}">${this.truncateText(node.name, 15)}</text> </g>`; } }); // 添加图例 svgContent += this.generateLegend(width, height); // 关闭SVG svgContent += ` </g> </svg>`; return svgContent; } private calculateNodePositions(nodes: Node[], edges: Edge[], width: number, height: number): Record<string, { x: number, y: number }> { const positions: Record<string, { x: number, y: number }> = {}; const nodeCount = nodes.length; if (nodeCount === 0) { return positions; } // 简单的力导向布局算法 // 初始化:均匀分布在一个圆上 const centerX = width / 2; const centerY = height / 2; const radius = Math.min(width, height) * 0.4; nodes.forEach((node, i) => { const angle = (2 * Math.PI * i) / nodeCount; positions[node.id] = { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }; }); // 应用力导向布局 const iterations = 50; for (let i = 0; i < iterations; i++) { // 计算节点间的斥力 const repulsionForce = 10000 / nodeCount; const forces: Record<string, { fx: number, fy: number }> = {}; nodes.forEach(node => { forces[node.id] = { fx: 0, fy: 0 }; }); // 节点间斥力 for (let j = 0; j < nodeCount; j++) { for (let k = j + 1; k < nodeCount; k++) { const node1 = nodes[j]; const node2 = nodes[k]; const pos1 = positions[node1.id]; const pos2 = positions[node2.id]; const dx = pos2.x - pos1.x; const dy = pos2.y - pos1.y; const distance = Math.max(1, Math.sqrt(dx * dx + dy * dy)); const force = repulsionForce / (distance * distance); const fx = (dx / distance) * force; const fy = (dy / distance) * force; forces[node1.id].fx -= fx; forces[node1.id].fy -= fy; forces[node2.id].fx += fx; forces[node2.id].fy += fy; } } // 边的吸引力 edges.forEach(edge => { const sourcePos = positions[edge.source]; const targetPos = positions[edge.target]; if (sourcePos && targetPos) { const dx = targetPos.x - sourcePos.x; const dy = targetPos.y - sourcePos.y; const distance = Math.max(1, Math.sqrt(dx * dx + dy * dy)); const force = distance / 30; const fx = (dx / distance) * force; const fy = (dy / distance) * force; forces[edge.source].fx += fx; forces[edge.source].fy += fy; forces[edge.target].fx -= fx; forces[edge.target].fy -= fy; } }); // 向中心的吸引力 nodes.forEach(node => { const pos = positions[node.id]; const dx = centerX - pos.x; const dy = centerY - pos.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > radius) { const force = (distance - radius) / 10; forces[node.id].fx += (dx / distance) * force; forces[node.id].fy += (dy / distance) * force; } }); // 应用力 const damping = 0.9; nodes.forEach(node => { const force = forces[node.id]; const pos = positions[node.id]; pos.x += force.fx * damping; pos.y += force.fy * damping; // 边界约束 pos.x = Math.max(this.settings.nodeRadius, Math.min(width - this.settings.nodeRadius, pos.x)); pos.y = Math.max(this.settings.nodeRadius, Math.min(height - this.settings.nodeRadius, pos.y)); }); } return positions; } private generateLegend(width: number, height: number): string { const { colors, fontSize, fontFamily } = this.settings; const legendX = width - 150; const legendY = 30; const rectSize = 10; const textOffset = 15; let legend = ` <g class="legend" transform="translate(${legendX}, ${legendY})"> <rect width="140" height="${Object.keys(NodeType).length * 20 + 10}" fill="white" fill-opacity="0.8" rx="5" ry="5" /> <text x="70" y="15" text-anchor="middle" font-family="${fontFamily}" font-size="${fontSize + 2}px" font-weight="bold">节点类型</text>`; Object.entries(NodeType).forEach(([key, value], index) => { const y = index * 20 + 30; const color = this.getNodeColor(value, colors); legend += ` <rect x="10" y="${y}" width="${rectSize}" height="${rectSize}" fill="${color}" /> <text x="${textOffset + 10}" y="${y + rectSize - 1}" font-family="${fontFamily}" font-size="${fontSize}px">${key}</text>`; }); legend += ` </g>`; return legend; } private getNodeColor(nodeType: string, colors: any): string { return colors[nodeType.toLowerCase()] || colors.default || '#95A5A6'; } private truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.substring(0, maxLength - 3) + '...'; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aiuluna/knowledge-graph-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server