Files
hms/apps/web/src/pages/graph/graphRenderer.ts
iven 0a57cd7030 refactor(web): 拆分 PluginGraphPage 为 graph 子模块 — 每个文件 < 800 行
- graphTypes.ts (39 行) — GraphNode/GraphEdge/GraphConfig/NodePosition/HoverState
- graphLayout.ts (41 行) — computeCircularLayout 环形布局算法
- graphRenderer.ts (293 行) — Canvas 绘制函数 + 常量 + helper
- PluginGraphPage.tsx (758 行) — 组件壳:state/effects/event handlers/JSX
2026-04-17 12:51:32 +08:00

294 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 关系图谱 — Canvas 绘制逻辑
*
* 纯函数模块,不依赖 React。所有绘制函数接收 CanvasRenderingContext2D
* 不持有状态,可安全在 requestAnimationFrame 循环中调用。
*/
import type { GraphEdge } from './graphTypes';
// ── 常量 ──
/** 关系类型对应的色板 (base / light / glow) */
export const RELATIONSHIP_COLORS: Record<string, { base: string; light: string; glow: string }> = {
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<string, string> = {
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();
}