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