/** * 关系图谱 — Canvas 绘制逻辑 * * 纯函数模块,不依赖 React。所有绘制函数接收 CanvasRenderingContext2D, * 不持有状态,可安全在 requestAnimationFrame 循环中调用。 */ import type { GraphEdge } from './graphTypes'; // ── 常量 ── /** 关系类型对应的色板 (base / light / glow) */ export const RELATIONSHIP_COLORS: Record = { parent_child: { base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' }, sibling: { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' }, partner: { base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' }, supplier: { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' }, competitor: { base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' }, }; /** 未匹配到已知关系类型时的默认色 */ export const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' }; /** 关系类型 → 中文标签 */ export const REL_LABEL_MAP: Record = { parent_child: '母子', sibling: '兄弟', partner: '伙伴', supplier: '供应商', competitor: '竞争', }; /** 普通节点基础半径 */ export const NODE_BASE_RADIUS = 18; /** 中心节点半径 */ export const NODE_CENTER_RADIUS = 26; /** 悬停放大系数 */ export const NODE_HOVER_SCALE = 1.3; /** 节点标签最大字符数 */ export const LABEL_MAX_LENGTH = 8; // ── Helper ── /** 根据 label 获取关系色板,未匹配时返回默认。 */ export function getRelColor(label: string) { return RELATIONSHIP_COLORS[label] || DEFAULT_REL_COLOR; } /** 将关系类型 key 转为中文标签。 */ export function getEdgeTypeLabel(label: string): string { return REL_LABEL_MAP[label] || label; } /** 截断过长标签。 */ export function truncateLabel(label: string): string { return label.length > LABEL_MAX_LENGTH ? label.slice(0, LABEL_MAX_LENGTH) + '...' : label; } /** 计算节点的度 (degree),即与之相连的边数。 */ export function getNodeDegree(nodeId: string, edges: GraphEdge[]): number { return edges.filter((e) => e.source === nodeId || e.target === nodeId).length; } /** 将 degree 映射为节点半径,连接越多节点越大。 */ export function degreeToRadius(degree: number, isCenter: boolean): number { const base = isCenter ? NODE_CENTER_RADIUS : NODE_BASE_RADIUS; const bonus = Math.min(degree * 1.2, 10); return base + bonus; } // ── 绘制函数 ── /** 绘制二次贝塞尔曲线边并带箭头,返回标签放置坐标。 */ export function drawCurvedEdge( ctx: CanvasRenderingContext2D, fromX: number, fromY: number, toX: number, toY: number, color: string, lineWidth: number, highlighted: boolean, alpha: number, ): { labelX: number; labelY: number } | undefined { const dx = toX - fromX; const dy = toY - fromY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 1) return undefined; // 控制点:边中点的垂直方向偏移 const curvature = Math.min(dist * 0.15, 30); const midX = (fromX + toX) / 2; const midY = (fromY + toY) / 2; const nx = -dy / dist; const ny = dx / dist; const cpX = midX + nx * curvature; const cpY = midY + ny * curvature; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = highlighted ? lineWidth + 1 : lineWidth; if (highlighted) { ctx.shadowColor = color; ctx.shadowBlur = 8; } ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.quadraticCurveTo(cpX, cpY, toX, toY); ctx.stroke(); // 箭头 const arrowT = 0.95; const tangentX = 2 * (1 - arrowT) * (cpX - fromX) + 2 * arrowT * (toX - cpX); const tangentY = 2 * (1 - arrowT) * (cpY - fromY) + 2 * arrowT * (toY - cpY); const tangentLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY); if (tangentLen < 1) { ctx.restore(); return undefined; } const arrowSize = highlighted ? 10 : 7; const ax = tangentX / tangentLen; const ay = tangentY / tangentLen; // 略微提前终点以避免覆盖节点圆 const endX = toX - ax * 4; const endY = toY - ay * 4; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(endX, endY); ctx.lineTo( endX - arrowSize * ax + arrowSize * 0.4 * ay, endY - arrowSize * ay - arrowSize * 0.4 * ax, ); ctx.lineTo( endX - arrowSize * ax - arrowSize * 0.4 * ay, endY - arrowSize * ay + arrowSize * 0.4 * ax, ); ctx.closePath(); ctx.fill(); ctx.restore(); return { labelX: cpX, labelY: cpY }; } /** 绘制带渐变填充和阴影的节点圆。 */ export function drawNode( ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, color: string, lightColor: string, glowColor: string, isCenter: boolean, isHovered: boolean, alpha: number, ): void { ctx.save(); ctx.globalAlpha = alpha; const r = isHovered ? radius * NODE_HOVER_SCALE : radius; // 外发光 / 阴影 if (isCenter || isHovered) { ctx.shadowColor = glowColor; ctx.shadowBlur = isCenter ? 20 : 14; } else { ctx.shadowColor = 'rgba(0,0,0,0.12)'; ctx.shadowBlur = 6; } // 渐变填充 const gradient = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r); if (isCenter) { gradient.addColorStop(0, lightColor); gradient.addColorStop(1, color); } else { gradient.addColorStop(0, 'rgba(255,255,255,0.9)'); gradient.addColorStop(1, 'rgba(255,255,255,0.4)'); } ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fillStyle = gradient; ctx.fill(); // 边框 ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.strokeStyle = isCenter ? color : lightColor; ctx.lineWidth = isCenter ? 3 : 1.5; if (isHovered) { ctx.lineWidth += 1; } ctx.stroke(); // 中心节点外圈虚线环 if (isCenter) { ctx.beginPath(); ctx.arc(x, y, r + 4, 0, 2 * Math.PI); ctx.strokeStyle = glowColor; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.stroke(); ctx.setLineDash([]); } ctx.restore(); } /** 绘制边标签(带背景胶囊)。 */ export function drawEdgeLabel( ctx: CanvasRenderingContext2D, x: number, y: number, label: string, color: string, alpha: number, ): void { ctx.save(); ctx.globalAlpha = alpha * 0.85; const display = getEdgeTypeLabel(label); ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const metrics = ctx.measureText(display); const textWidth = metrics.width; const padding = 6; const pillHeight = 18; const pillRadius = 9; // 背景胶囊 const pillX = x - textWidth / 2 - padding; const pillY = y - pillHeight / 2; ctx.fillStyle = color + '18'; ctx.beginPath(); ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius); ctx.fill(); ctx.strokeStyle = color + '40'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius); ctx.stroke(); // 文本 ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(display, x, y); ctx.restore(); } /** 在节点下方绘制节点标签。 */ export function drawNodeLabel( ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, label: string, textColor: string, isCenter: boolean, isHovered: boolean, ): void { ctx.save(); const display = truncateLabel(label); const r = isHovered ? radius * NODE_HOVER_SCALE : radius; const fontSize = isCenter ? 13 : isHovered ? 12 : 11; const fontWeight = isCenter ? '600' : isHovered ? '500' : '400'; ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = textColor; if (isCenter || isHovered) { ctx.globalAlpha = 1; } else { ctx.globalAlpha = 0.7; } ctx.fillText(display, x, y + r + 8); ctx.restore(); }