Download src/components/editor/RelationshipGraph.tsx from Leon4gr45/Velo: direct link, hf CLI and curl.
- Browser
- Download file 6.85 kB
-
https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/editor/RelationshipGraph.tsx
- Command line
-
hf download hf://spaces/Leon4gr45/Velo/src/components/editor/RelationshipGraph.tsx
-
curl -L -o RelationshipGraph.tsx https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/editor/RelationshipGraph.tsx
6.85 kB
| import { useRef, useEffect } from 'react' | |
| import { useTranslation } from 'react-i18next' | |
| interface CharacterNode { | |
| name: string | |
| role: string | |
| x: number | |
| y: number | |
| vx: number | |
| vy: number | |
| } | |
| interface RelationshipEdge { | |
| from: string | |
| to: string | |
| label: string | |
| } | |
| interface RelationshipGraphProps { | |
| characters: Array<{ | |
| name: string | |
| role: string | |
| relationships: string | |
| }> | |
| } | |
| /** 从角色关系文本中解析出关系边 */ | |
| function parseRelationships(characters: RelationshipGraphProps['characters']): RelationshipEdge[] { | |
| const edges: RelationshipEdge[] = [] | |
| for (const char of characters) { | |
| if (!char.relationships) continue | |
| // 尝试多种格式:JSON 数组 / "名字:关系" / "名字 - 关系" | |
| try { | |
| const parsed = JSON.parse(char.relationships) | |
| if (Array.isArray(parsed)) { | |
| for (const rel of parsed) { | |
| const targetName = rel.name || rel.target | |
| if (targetName && characters.some(c => c.name === targetName)) { | |
| edges.push({ from: char.name, to: targetName, label: rel.relation || rel.label || '' }) | |
| } | |
| } | |
| continue | |
| } | |
| } catch { /* 不是 JSON,继续用文本解析 */ } | |
| // 文本格式解析 | |
| const lines = char.relationships.split(/[,;,;\n]/).filter(Boolean) | |
| for (const line of lines) { | |
| const match = line.match(/(.+?)[::—-]\s*(.+)/) | |
| if (match) { | |
| const targetName = match[1].trim() | |
| const label = match[2].trim() | |
| if (characters.some(c => c.name === targetName)) { | |
| edges.push({ from: char.name, to: targetName, label }) | |
| } | |
| } | |
| } | |
| } | |
| return edges | |
| } | |
| const ROLE_COLORS: Record<string, string> = { | |
| protagonist: '#6ee7b7', | |
| antagonist: '#fca5a5', | |
| supporting: '#93c5fd', | |
| minor: '#a78bfa', | |
| } | |
| /** 角色关系网 Canvas 可视化 */ | |
| export default function RelationshipGraph({ characters }: RelationshipGraphProps) { | |
| const { t } = useTranslation('editors') | |
| const canvasRef = useRef<HTMLCanvasElement>(null) | |
| const nodesRef = useRef<CharacterNode[]>([]) | |
| const animRef = useRef<number>(0) | |
| const edges = parseRelationships(characters) | |
| // 初始化节点布局 | |
| useEffect(() => { | |
| const canvas = canvasRef.current | |
| if (!canvas) return | |
| const w = canvas.offsetWidth | |
| const h = canvas.offsetHeight | |
| canvas.width = w * 2 | |
| canvas.height = h * 2 | |
| const centerX = w | |
| const centerY = h | |
| const radius = Math.min(w, h) * 0.6 | |
| // 环形初始布局 | |
| nodesRef.current = characters.map((c, i) => { | |
| const angle = (i / characters.length) * Math.PI * 2 - Math.PI / 2 | |
| return { | |
| name: c.name, | |
| role: c.role, | |
| x: centerX + radius * Math.cos(angle), | |
| y: centerY + radius * Math.sin(angle), | |
| vx: 0, | |
| vy: 0, | |
| } | |
| }) | |
| // 启动力导向模拟 | |
| let iteration = 0 | |
| const maxIterations = 120 | |
| const drawFrame = () => { | |
| const canvas = canvasRef.current | |
| if (!canvas) return | |
| const ctx = canvas.getContext('2d') | |
| if (!ctx) return | |
| const nodes = nodesRef.current | |
| ctx.clearRect(0, 0, canvas.width, canvas.height) | |
| // 绘制连线 | |
| ctx.lineWidth = 1.5 | |
| for (const edge of edges) { | |
| const a = nodes.find((n) => n.name === edge.from) | |
| const b = nodes.find((n) => n.name === edge.to) | |
| if (!a || !b) continue | |
| ctx.beginPath() | |
| ctx.moveTo(a.x, a.y) | |
| ctx.lineTo(b.x, b.y) | |
| ctx.strokeStyle = 'rgba(148,163,184,0.3)' | |
| ctx.stroke() | |
| // 关系标签 | |
| if (edge.label) { | |
| const mx = (a.x + b.x) / 2 | |
| const my = (a.y + b.y) / 2 | |
| ctx.font = '18px system-ui' | |
| ctx.fillStyle = 'rgba(148,163,184,0.6)' | |
| ctx.textAlign = 'center' | |
| ctx.fillText(edge.label, mx, my - 4) | |
| } | |
| } | |
| // 绘制节点 | |
| for (const node of nodes) { | |
| const color = ROLE_COLORS[node.role] || '#94a3b8' | |
| // 光晕 | |
| ctx.beginPath() | |
| ctx.arc(node.x, node.y, 28, 0, Math.PI * 2) | |
| ctx.fillStyle = color + '25' | |
| ctx.fill() | |
| // 节点 | |
| ctx.beginPath() | |
| ctx.arc(node.x, node.y, 20, 0, Math.PI * 2) | |
| ctx.fillStyle = color + '40' | |
| ctx.fill() | |
| ctx.strokeStyle = color | |
| ctx.lineWidth = 2 | |
| ctx.stroke() | |
| // 名字 | |
| ctx.font = 'bold 22px system-ui' | |
| ctx.fillStyle = color | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| ctx.fillText(node.name, node.x, node.y + 36) | |
| } | |
| } | |
| const simulate = () => { | |
| const nodes = nodesRef.current | |
| if (iteration >= maxIterations) { | |
| drawFrame() | |
| return | |
| } | |
| // 斥力(节点间) | |
| for (let i = 0; i < nodes.length; i++) { | |
| for (let j = i + 1; j < nodes.length; j++) { | |
| const dx = nodes[j].x - nodes[i].x | |
| const dy = nodes[j].y - nodes[i].y | |
| const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1) | |
| const force = 8000 / (dist * dist) | |
| const fx = (dx / dist) * force | |
| const fy = (dy / dist) * force | |
| nodes[i].vx -= fx | |
| nodes[i].vy -= fy | |
| nodes[j].vx += fx | |
| nodes[j].vy += fy | |
| } | |
| } | |
| // 引力(连线间) | |
| for (const edge of edges) { | |
| const a = nodes.find((n) => n.name === edge.from) | |
| const b = nodes.find((n) => n.name === edge.to) | |
| if (!a || !b) continue | |
| const dx = b.x - a.x | |
| const dy = b.y - a.y | |
| const dist = Math.sqrt(dx * dx + dy * dy) | |
| const force = (dist - 150) * 0.01 | |
| const fx = (dx / dist) * force | |
| const fy = (dy / dist) * force | |
| a.vx += fx | |
| a.vy += fy | |
| b.vx -= fx | |
| b.vy -= fy | |
| } | |
| // 向心力 | |
| for (const node of nodes) { | |
| node.vx += (centerX - node.x) * 0.002 | |
| node.vy += (centerY - node.y) * 0.002 | |
| } | |
| // 应用速度 + 阻尼 | |
| const damping = 0.85 | |
| for (const node of nodes) { | |
| node.vx *= damping | |
| node.vy *= damping | |
| node.x += node.vx | |
| node.y += node.vy | |
| // 边界约束 | |
| node.x = Math.max(40, Math.min(w * 2 - 40, node.x)) | |
| node.y = Math.max(40, Math.min(h * 2 - 40, node.y)) | |
| } | |
| iteration++ | |
| drawFrame() | |
| animRef.current = requestAnimationFrame(simulate) | |
| } | |
| simulate() | |
| return () => cancelAnimationFrame(animRef.current) | |
| }, [characters, edges]) | |
| if (characters.length === 0) { | |
| return ( | |
| <div className="flex items-center justify-center h-full text-xs text-[var(--color-text-muted)]"> | |
| {t('relationshipGraph.noData')} | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <canvas | |
| ref={canvasRef} | |
| className="w-full h-full" | |
| style={{ background: 'transparent' }} | |
| /> | |
| ) | |
| } | |