'use client'; /** * V3RuleNode — v3 시안 v3-canvas.jsx 의 V3RuleNode 정확 포팅 * cat-color stripe + validation dot + comment avatar + cat-chip header + label + summary + stats + ports */ import { CTRL_NODE_TYPES } from '../hooks/useControlMode'; import { getNodeIcon } from '../schemas'; import type { NodeStats, NodeComment } from '@/lib/api/control'; interface V3RuleNodeProps { node: Record & { cx: number; cy: number }; scale: number; selected: boolean; dim: boolean; stats?: NodeStats; comments?: NodeComment[]; onSelect: () => void; onDrag: (dx: number, dy: number) => void; onContextMenu: (canvasX: number, canvasY: number) => void; } export function V3RuleNode({ node, scale, selected, dim, stats, comments, onSelect, onDrag, onContextMenu, }: V3RuleNodeProps) { const def = CTRL_NODE_TYPES[node.type]; if (!def) return null; const rgb = def.rgb; const Ic = getNodeIcon(node.type); // Pointer Events + setPointerCapture — transform/scale 안에서도 mouse 이벤트 안정적으로 받음 const onPointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest('button, input, select, textarea')) return; e.stopPropagation(); const el = e.currentTarget as HTMLDivElement; const pointerId = e.pointerId; try { el.setPointerCapture(pointerId); } catch { /* unsupported */ } const start = { x: e.clientX, y: e.clientY }; let moved = false; const onMove = (ev: Event) => { const pe = ev as PointerEvent; const dx = (pe.clientX - start.x) / (scale || 1); const dy = (pe.clientY - start.y) / (scale || 1); if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return; moved = true; onDrag(dx, dy); start.x = pe.clientX; start.y = pe.clientY; }; const onUp = () => { try { el.releasePointerCapture(pointerId); } catch { /* */ } el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerup', onUp); el.removeEventListener('pointercancel', onUp); if (!moved) onSelect(); }; el.addEventListener('pointermove', onMove); el.addEventListener('pointerup', onUp); el.addEventListener('pointercancel', onUp); }; const onCtx = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onContextMenu(node.cx, node.cy + 70); }; const lastMs = stats?.lastMs ?? null; const latColor = lastMs == null ? undefined : lastMs < 30 ? 'var(--v5-green)' : lastMs < 100 ? 'var(--v5-text-sec)' : 'var(--v5-amber)'; const summary = node.summary?.[0] ?? (node.config ? Object.entries(node.config).slice(0, 1).map(([k, v]) => `${k}: ${v}`)[0] : null); const firstComment = comments?.[0]; return (
{/* cat-color stripe */}
{/* validation dot */} {stats && ( )} {/* comment avatar */} {firstComment && ( {firstComment.short} )} {/* body */}
{def.label}
{node.label ?? def.label}
{summary &&
{summary}
} {stats && (
{stats.runs.toLocaleString()} runs {lastMs != null && ( {lastMs}ms )}
)}
{/* ports */}
); }