- 替换 DESIGN.md 为 Pinterest 设计规格(暖色调、红色主题、大圆角) - 更新 CSS 变量:主色 #0075de→#e60023, 圆角 4px→16px, 背景 #f6f5f4→#f6f6f3 - 更新 Ant Design 主题令牌:更大圆角、Pinterest 色板、更大触控目标 - 批量更新 24 个页面/组件文件中的硬编码颜色值 - 暗色模式同步适配 Pinterest 暖色调暗色方案
313 lines
8.7 KiB
TypeScript
313 lines
8.7 KiB
TypeScript
/**
|
||
* 关系图谱 — 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();
|
||
}
|