/** * Graph Canvas 渲染 — 边/节点/标签绘制函数 */ import type { NodePosition } from './graphTypes'; import { degreeToRadius } from './graphLayout'; import { getEdgeColor, DEFAULT_REL_COLOR, NODE_HOVER_SCALE, LABEL_MAX_LENGTH, } from './graphConstants'; // ── Helper 函数 ── export function getRelColor(label: string) { return getEdgeColor(label) || DEFAULT_REL_COLOR; } export function getEdgeTypeLabel(label: string): string { return label; } function truncateLabel(label: string): string { return label.length > LABEL_MAX_LENGTH ? label.slice(0, LABEL_MAX_LENGTH) + '...' : label; } // ── 渲染函数 ── /** 绘制贝塞尔曲线边 + 箭头 */ export function drawCurvedEdge( ctx: CanvasRenderingContext2D, fromX: number, fromY: number, toX: number, toY: number, color: string, lineWidth: number, highlighted: boolean, alpha: number, ) { const dx = toX - fromX; const dy = toY - fromY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 1) return; 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 t = 0.95; const tangentX = 2 * (1 - t) * (cpX - fromX) + 2 * t * (toX - cpX); const tangentY = 2 * (1 - t) * (cpY - fromY) + 2 * t * (toY - cpY); const tangentLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY); if (tangentLen < 1) { ctx.restore(); return; } 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, ) { 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, _textColor: string, alpha: number, ) { 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, ) { 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(); } /** 绘制完整图谱 (edges + nodes + hover tooltip) */ export function drawFullGraph( ctx: CanvasRenderingContext2D, width: number, height: number, positions: Map, nodes: { id: string; label: string; data: Record }[], edges: { source: string; target: string; label: string }[], selectedCenter: string | null, hoverState: { nodeId: string | null; x: number; y: number }, token: { colorText: string; colorBgContainer: string; colorBgElevated: string; colorBorderSecondary: string; }, degreeMap: Map, ) { const textColor = token.colorText; const bgColor = token.colorBgContainer; ctx.clearRect(0, 0, width, height); ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, height); if (nodes.length === 0) return; // 绘制边 (在节点下方) for (const edge of edges) { const from = positions.get(edge.source); const to = positions.get(edge.target); if (!from || !to) continue; const colors = getRelColor(edge.label); const isHighlighted = hoverState.nodeId === edge.source || hoverState.nodeId === edge.target; const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7; const lw = isHighlighted ? 2.5 : 1.5; const labelPos = drawCurvedEdge( ctx, from.x, from.y, to.x, to.y, colors.base, lw, isHighlighted, alpha, ); if (edge.label && labelPos) { const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9; drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, textColor, labelAlpha); } } // 绘制节点 for (const node of nodes) { const pos = positions.get(node.id); if (!pos) continue; const isCenter = node.id === selectedCenter; const isHovered = node.id === hoverState.nodeId; const degree = degreeMap.get(node.id) || 0; const r = degreeToRadius(degree, isCenter); let nodeColorBase = '#e60023'; let nodeColorLight = '#f05a5a'; let nodeColorGlow = 'rgba(79,70,229,0.3)'; if (isCenter) { const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id); if (firstEdge) { const rc = getRelColor(firstEdge.label); nodeColorBase = rc.base; nodeColorLight = rc.light; nodeColorGlow = rc.glow; } } else { const idx = nodes.indexOf(node); const pick = getEdgeColor(`_node_${idx}`); nodeColorBase = pick.base; nodeColorLight = pick.light; nodeColorGlow = pick.glow; } const nodeAlpha = hoverState.nodeId ? (isHovered || (hoverState.nodeId && edges.some( (e) => (e.source === hoverState.nodeId && e.target === node.id) || (e.target === hoverState.nodeId && e.source === node.id), )) ? 1 : 0.2) : 1; drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha); drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered); } // Hover tooltip if (hoverState.nodeId) { const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId); if (hoveredNode) { const degree = degreeMap.get(hoveredNode.id) || 0; const tooltipText = `${hoveredNode.label} (${degree} 条关系)`; ctx.save(); ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const metrics = ctx.measureText(tooltipText); const tw = metrics.width + 16; const th = 28; const tx = hoverState.x - tw / 2; const ty = hoverState.y - 40; ctx.fillStyle = token.colorBgElevated; ctx.shadowColor = 'rgba(0,0,0,0.15)'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 6); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = token.colorText; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(tooltipText, hoverState.x, ty + th / 2); ctx.restore(); } } }