2f398ae0b3
- 제어모드 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 후속 노트
160 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|