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
This commit is contained in:
@@ -33,342 +33,23 @@ import {
|
|||||||
type PluginSchemaResponse,
|
type PluginSchemaResponse,
|
||||||
} from '../api/plugins';
|
} from '../api/plugins';
|
||||||
|
|
||||||
|
import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes';
|
||||||
|
import { computeCircularLayout } from './graph/graphLayout';
|
||||||
|
import {
|
||||||
|
RELATIONSHIP_COLORS,
|
||||||
|
NODE_HOVER_SCALE,
|
||||||
|
getRelColor,
|
||||||
|
getEdgeTypeLabel,
|
||||||
|
getNodeDegree,
|
||||||
|
degreeToRadius,
|
||||||
|
drawCurvedEdge,
|
||||||
|
drawNode,
|
||||||
|
drawEdgeLabel,
|
||||||
|
drawNodeLabel,
|
||||||
|
} from './graph/graphRenderer';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// ── Types ──
|
|
||||||
|
|
||||||
interface GraphNode {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphEdge {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphConfig {
|
|
||||||
entity: string;
|
|
||||||
relationshipEntity: string;
|
|
||||||
sourceField: string;
|
|
||||||
targetField: string;
|
|
||||||
edgeLabelField: string;
|
|
||||||
nodeLabelField: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NodePosition {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HoverState {
|
|
||||||
nodeId: string | null;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Constants ──
|
|
||||||
|
|
||||||
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)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
|
|
||||||
|
|
||||||
const REL_LABEL_MAP: Record<string, string> = {
|
|
||||||
parent_child: '母子',
|
|
||||||
sibling: '兄弟',
|
|
||||||
partner: '伙伴',
|
|
||||||
supplier: '供应商',
|
|
||||||
competitor: '竞争',
|
|
||||||
};
|
|
||||||
|
|
||||||
const NODE_BASE_RADIUS = 18;
|
|
||||||
const NODE_CENTER_RADIUS = 26;
|
|
||||||
const NODE_HOVER_SCALE = 1.3;
|
|
||||||
const LABEL_MAX_LENGTH = 8;
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
function getRelColor(label: string) {
|
|
||||||
return RELATIONSHIP_COLORS[label] || DEFAULT_REL_COLOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEdgeTypeLabel(label: string): string {
|
|
||||||
return REL_LABEL_MAP[label] || label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateLabel(label: string): string {
|
|
||||||
return label.length > LABEL_MAX_LENGTH
|
|
||||||
? label.slice(0, LABEL_MAX_LENGTH) + '...'
|
|
||||||
: label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute color for a node based on its connection count (degree). */
|
|
||||||
function getNodeDegree(nodeId: string, edges: GraphEdge[]): number {
|
|
||||||
return edges.filter((e) => e.source === nodeId || e.target === nodeId).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map a degree value to a node radius — more connections = larger node. */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Calculate circular layout positions. */
|
|
||||||
function computeCircularLayout(
|
|
||||||
nodes: GraphNode[],
|
|
||||||
centerX: number,
|
|
||||||
centerY: number,
|
|
||||||
radius: number,
|
|
||||||
): Map<string, NodePosition> {
|
|
||||||
const positions = new Map<string, NodePosition>();
|
|
||||||
const count = nodes.length;
|
|
||||||
if (count === 0) return positions;
|
|
||||||
|
|
||||||
if (count === 1) {
|
|
||||||
positions.set(nodes[0].id, { x: centerX, y: centerY, vx: 0, vy: 0 });
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.forEach((node, i) => {
|
|
||||||
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
|
|
||||||
positions.set(node.id, {
|
|
||||||
x: centerX + radius * Math.cos(angle),
|
|
||||||
y: centerY + radius * Math.sin(angle),
|
|
||||||
vx: 0,
|
|
||||||
vy: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draw a quadratic bezier curved edge with an arrowhead. */
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Control point offset perpendicular to the edge midpoint
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Arrowhead at target end
|
|
||||||
const t = 0.95;
|
|
||||||
const arrowT = t;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrowSize = highlighted ? 10 : 7;
|
|
||||||
const ax = tangentX / tangentLen;
|
|
||||||
const ay = tangentY / tangentLen;
|
|
||||||
|
|
||||||
// Point slightly before target to avoid overlapping the node circle
|
|
||||||
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 the midpoint for label placement
|
|
||||||
return { labelX: cpX, labelY: cpY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draw a gradient-filled node circle with shadow. */
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Outer glow / shadow
|
|
||||||
if (isCenter || isHovered) {
|
|
||||||
ctx.shadowColor = glowColor;
|
|
||||||
ctx.shadowBlur = isCenter ? 20 : 14;
|
|
||||||
} else {
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.12)';
|
|
||||||
ctx.shadowBlur = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gradient fill
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Border
|
|
||||||
ctx.shadowColor = 'transparent';
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
ctx.strokeStyle = isCenter ? color : lightColor;
|
|
||||||
ctx.lineWidth = isCenter ? 3 : 1.5;
|
|
||||||
if (isHovered) {
|
|
||||||
ctx.lineWidth += 1;
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Inner highlight ring for center node
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draw the edge label with background pill. */
|
|
||||||
function drawEdgeLabel(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
label: string,
|
|
||||||
color: 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;
|
|
||||||
|
|
||||||
// Background pill
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Text
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(display, x, y);
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draw a node label below the node. */
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插件关系图谱页面 — 通过路由参数自加载 schema
|
* 插件关系图谱页面 — 通过路由参数自加载 schema
|
||||||
* 路由: /plugins/:pluginId/graph/:entityName
|
* 路由: /plugins/:pluginId/graph/:entityName
|
||||||
@@ -637,7 +318,6 @@ export function PluginGraphPage() {
|
|||||||
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
||||||
|
|
||||||
if (isCenter) {
|
if (isCenter) {
|
||||||
// Use the first connected edge color for center node
|
|
||||||
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
|
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
|
||||||
if (firstEdge) {
|
if (firstEdge) {
|
||||||
const rc = getRelColor(firstEdge.label);
|
const rc = getRelColor(firstEdge.label);
|
||||||
@@ -646,7 +326,6 @@ export function PluginGraphPage() {
|
|||||||
nodeColorGlow = rc.glow;
|
nodeColorGlow = rc.glow;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Assign color based on index for non-center nodes
|
|
||||||
const idx = nodes.indexOf(node);
|
const idx = nodes.indexOf(node);
|
||||||
const palette = Object.values(RELATIONSHIP_COLORS);
|
const palette = Object.values(RELATIONSHIP_COLORS);
|
||||||
const pick = palette[idx % palette.length];
|
const pick = palette[idx % palette.length];
|
||||||
|
|||||||
41
apps/web/src/pages/graph/graphLayout.ts
Normal file
41
apps/web/src/pages/graph/graphLayout.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 关系图谱 — 布局算法
|
||||||
|
*
|
||||||
|
* 纯函数模块,不依赖 React。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GraphNode, NodePosition } from './graphTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算环形布局位置。
|
||||||
|
*
|
||||||
|
* 节点均匀分布在以 (centerX, centerY) 为圆心、radius 为半径的圆周上。
|
||||||
|
* 单个节点退化为圆心;两个及以上节点按角度排列,起始角在正上方 (-PI/2)。
|
||||||
|
*/
|
||||||
|
export function computeCircularLayout(
|
||||||
|
nodes: GraphNode[],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
radius: number,
|
||||||
|
): Map<string, NodePosition> {
|
||||||
|
const positions = new Map<string, NodePosition>();
|
||||||
|
const count = nodes.length;
|
||||||
|
if (count === 0) return positions;
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
positions.set(nodes[0].id, { x: centerX, y: centerY, vx: 0, vy: 0 });
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach((node, i) => {
|
||||||
|
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
|
||||||
|
positions.set(node.id, {
|
||||||
|
x: centerX + radius * Math.cos(angle),
|
||||||
|
y: centerY + radius * Math.sin(angle),
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
293
apps/web/src/pages/graph/graphRenderer.ts
Normal file
293
apps/web/src/pages/graph/graphRenderer.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* 关系图谱 — 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();
|
||||||
|
}
|
||||||
39
apps/web/src/pages/graph/graphTypes.ts
Normal file
39
apps/web/src/pages/graph/graphTypes.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 关系图谱 — 类型定义
|
||||||
|
*
|
||||||
|
* 仅导出接口类型,不含运行时代码。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphConfig {
|
||||||
|
entity: string;
|
||||||
|
relationshipEntity: string;
|
||||||
|
sourceField: string;
|
||||||
|
targetField: string;
|
||||||
|
edgeLabelField: string;
|
||||||
|
nodeLabelField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodePosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoverState {
|
||||||
|
nodeId: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user