Files
erp/apps/web/src/pages/graph/graphRenderer.ts
iven 85e732cf12
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat(web): 从 Notion 风格切换到 Pinterest 设计系统
- 替换 DESIGN.md 为 Pinterest 设计规格(暖色调、红色主题、大圆角)
- 更新 CSS 变量:主色 #0075de→#e60023, 圆角 4px→16px, 背景 #f6f5f4→#f6f6f3
- 更新 Ant Design 主题令牌:更大圆角、Pinterest 色板、更大触控目标
- 批量更新 24 个页面/组件文件中的硬编码颜色值
- 暗色模式同步适配 Pinterest 暖色调暗色方案
2026-04-20 22:13:20 +08:00

313 lines
8.7 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) — 通用调色板自动分配 */
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#e60023', light: '#f05a5a', glow: 'rgba(79,70,229,0.3)' },
{ base: '#103c25', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#b56e1a', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#9e0a0a', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },
];
/** 未匹配到已知关系类型时的默认色 */
export const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
const edgeColorCache = new Map<string, { base: string; light: string; glow: string }>();
export function getEdgeColorGlobal(label: string) {
const cached = edgeColorCache.get(label);
if (cached) return cached;
const color = EDGE_PALETTE[edgeColorCache.size % EDGE_PALETTE.length];
edgeColorCache.set(label, color);
return color;
}
/** @deprecated 使用 getEdgeColorGlobal */
export const RELATIONSHIP_COLORS = new Proxy({} as Record<string, { base: string; light: string; glow: string }>, {
get(_, prop: string) { return getEdgeColorGlobal(prop); },
});
/** @deprecated 标签直接使用原始值 */
export const REL_LABEL_MAP = new Proxy({} as Record<string, string>, {
get(_, prop: string) { return prop; },
});
/** 通用边颜色函数 — 兼容旧路径导入 */
export function getEdgeColor(label: string) {
return getEdgeColorGlobal(label);
}
/** 普通节点基础半径 */
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();
}