Files
invyone/frontend/components/control/ide/V3RuleNode.tsx
T
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

160 lines
5.3 KiB
TypeScript

'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<string, any> & { 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 (
<div
data-pz-node="true"
draggable={false}
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
onPointerDown={onPointerDown}
onContextMenu={onCtx}
style={{
left: node.cx, top: node.cy,
borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
boxShadow: selected
? `0 0 0 4px rgba(${rgb}, .14), 0 0 24px rgba(${rgb}, .22)`
: '0 4px 12px -4px rgba(0, 0, 0, .08)',
touchAction: 'none',
}}
>
{/* cat-color stripe */}
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
{/* validation dot */}
{stats && (
<span
className="v3-rule-node-vdot"
style={{
background: stats.valid
? (stats.alert ? 'var(--v5-amber)' : 'var(--v5-green)')
: 'var(--v5-red)',
boxShadow: stats.valid
? (stats.alert ? '0 0 5px var(--v5-amber)' : '0 0 5px var(--v5-green)')
: '0 0 5px var(--v5-red)',
}}
title={stats.alert || (stats.valid ? '정상' : '검증 실패')}
/>
)}
{/* comment avatar */}
{firstComment && (
<span
className="v3-rule-node-comment"
title={firstComment.text}
style={{ background: `rgb(${firstComment.color})` }}
>
{firstComment.short}
</span>
)}
{/* body */}
<div className="v3-rule-node-body">
<div className="v3-rule-node-cat">
<div
className="v3-rule-node-cat-ico"
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
>
<Ic size={11} />
</div>
<span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
{def.label}
</span>
</div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</div>}
{stats && (
<div className="v3-rule-node-stats">
<span>{stats.runs.toLocaleString()} runs</span>
{lastMs != null && (
<span style={{ fontWeight: 700, color: latColor }}>{lastMs}ms</span>
)}
</div>
)}
</div>
{/* ports */}
<div className="v3-rule-node-port v3-rule-node-port-in"
style={{ borderColor: `rgb(${rgb})` }} />
<div className="v3-rule-node-port v3-rule-node-port-out"
style={{ background: `rgb(${rgb})` }} />
</div>
);
}