From 0a57cd703096d17f0f7ef6344fed7d73f77b8d00 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 12:51:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E6=8B=86=E5=88=86=20PluginGra?= =?UTF-8?q?phPage=20=E4=B8=BA=20graph=20=E5=AD=90=E6=A8=A1=E5=9D=97=20?= =?UTF-8?q?=E2=80=94=20=E6=AF=8F=E4=B8=AA=E6=96=87=E4=BB=B6=20<=20800=20?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/src/pages/PluginGraphPage.tsx | 351 +--------------------- apps/web/src/pages/graph/graphLayout.ts | 41 +++ apps/web/src/pages/graph/graphRenderer.ts | 293 ++++++++++++++++++ apps/web/src/pages/graph/graphTypes.ts | 39 +++ 4 files changed, 388 insertions(+), 336 deletions(-) create mode 100644 apps/web/src/pages/graph/graphLayout.ts create mode 100644 apps/web/src/pages/graph/graphRenderer.ts create mode 100644 apps/web/src/pages/graph/graphTypes.ts diff --git a/apps/web/src/pages/PluginGraphPage.tsx b/apps/web/src/pages/PluginGraphPage.tsx index 8639f6a..d7038b1 100644 --- a/apps/web/src/pages/PluginGraphPage.tsx +++ b/apps/web/src/pages/PluginGraphPage.tsx @@ -33,342 +33,23 @@ import { type PluginSchemaResponse, } 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; -// ── Types ── - -interface GraphNode { - id: string; - label: string; - data: Record; -} - -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 = { - 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 = { - 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 { - const positions = new Map(); - 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 * 路由: /plugins/:pluginId/graph/:entityName @@ -637,7 +318,6 @@ export function PluginGraphPage() { let nodeColorGlow = 'rgba(79,70,229,0.3)'; if (isCenter) { - // Use the first connected edge color for center node const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id); if (firstEdge) { const rc = getRelColor(firstEdge.label); @@ -646,7 +326,6 @@ export function PluginGraphPage() { nodeColorGlow = rc.glow; } } else { - // Assign color based on index for non-center nodes const idx = nodes.indexOf(node); const palette = Object.values(RELATIONSHIP_COLORS); const pick = palette[idx % palette.length]; diff --git a/apps/web/src/pages/graph/graphLayout.ts b/apps/web/src/pages/graph/graphLayout.ts new file mode 100644 index 0000000..fc7d0fd --- /dev/null +++ b/apps/web/src/pages/graph/graphLayout.ts @@ -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 { + const positions = new Map(); + 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; +} diff --git a/apps/web/src/pages/graph/graphRenderer.ts b/apps/web/src/pages/graph/graphRenderer.ts new file mode 100644 index 0000000..abbdcce --- /dev/null +++ b/apps/web/src/pages/graph/graphRenderer.ts @@ -0,0 +1,293 @@ +/** + * 关系图谱 — Canvas 绘制逻辑 + * + * 纯函数模块,不依赖 React。所有绘制函数接收 CanvasRenderingContext2D, + * 不持有状态,可安全在 requestAnimationFrame 循环中调用。 + */ + +import type { GraphEdge } from './graphTypes'; + +// ── 常量 ── + +/** 关系类型对应的色板 (base / light / glow) */ +export const RELATIONSHIP_COLORS: Record = { + 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 = { + 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(); +} diff --git a/apps/web/src/pages/graph/graphTypes.ts b/apps/web/src/pages/graph/graphTypes.ts new file mode 100644 index 0000000..84299de --- /dev/null +++ b/apps/web/src/pages/graph/graphTypes.ts @@ -0,0 +1,39 @@ +/** + * 关系图谱 — 类型定义 + * + * 仅导出接口类型,不含运行时代码。 + */ + +export interface GraphNode { + id: string; + label: string; + data: Record; +} + +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; +}