Files
invyone/frontend/components/control/ConnectionLine.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

174 lines
6.0 KiB
TypeScript

'use client';
/**
* SVG 연결선 + 화살표 마커 4종 + 뱃지
* mockup drawTreeLine/addEdgeBadge 포팅
*/
interface ConnectionSvgProps {
children?: React.ReactNode;
}
/** SVG 컨테이너 (캔버스 위 오버레이, defs 포함) */
export function ConnectionSvg({ children }: ConnectionSvgProps) {
return (
<svg className="ctrl-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<marker id="arr-fk" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".7" />
</marker>
<marker id="arr-auto" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-primary)" opacity=".8" />
</marker>
<marker id="arr-cond" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-amber)" opacity=".8" />
</marker>
<marker id="arr-src" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-pink)" opacity=".8" />
</marker>
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
</marker>
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
</marker>
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
</marker>
</defs>
{children}
</svg>
);
}
/**
* 연결선 path — mockup v3 EditCanvas 의 orthogonal-with-rounded-corners 스타일
* from(x1,y1) → 가로 → 둥근 코너 → 세로 → 둥근 코너 → 가로 → to(x2,y2)
* 같은 y 면 직선, 역방향(x1>x2)이면 부드러운 베지어로 fallback (어색한 backward 회피)
*/
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
// 역방향 (오른쪽→왼쪽): 직각 라우팅이 카드 위로 휘감으면 어색 → 베지어 사용
if (x2 < x1 - 20) {
const dx = x2 - x1;
return `M ${x1} ${y1} C ${x1 + Math.abs(dx) * 0.4} ${y1}, ${x2 - Math.abs(dx) * 0.4} ${y2}, ${x2} ${y2}`;
}
const sign = Math.sign(y2 - y1);
if (sign === 0) return `M ${x1} ${y1} L ${x2} ${y2}`;
const mx = (x1 + x2) / 2;
const r = Math.min(10, Math.abs(y2 - y1) / 2, Math.abs(x2 - x1) / 4);
return `M ${x1} ${y1} L ${mx - r} ${y1} Q ${mx} ${y1}, ${mx} ${y1 + sign * r} L ${mx} ${y2 - sign * r} Q ${mx} ${y2}, ${mx + r} ${y2} L ${x2} ${y2}`;
}
/** 타입별 CSS 클래스 + 마커 */
export function lineStyle(type: string): { cls: string; marker: string } {
switch (type) {
case 'source': return { cls: 'ctrl-line-tpl', marker: 'url(#arr-src)' };
case 'auto': return { cls: 'ctrl-line-auto', marker: 'url(#arr-auto)' };
case 'cond': return { cls: 'ctrl-line-cond', marker: 'url(#arr-cond)' };
default: return { cls: 'ctrl-line', marker: 'url(#arr-fk)' };
}
}
interface FlowLineProps {
x1: number; y1: number;
x2: number; y2: number;
type: string;
animate?: boolean;
delay?: number;
}
/** 단일 연결선 (SVG path) */
export function FlowLine({ x1, y1, x2, y2, type, animate, delay }: FlowLineProps) {
const { cls, marker } = lineStyle(type);
const d = bezierPath(x1, y1, x2, y2);
if (animate) {
return (
<path
d={d}
className={cls}
markerEnd={marker}
style={{
strokeDasharray: '1000',
strokeDashoffset: '1000',
animation: 'none',
transition: `stroke-dashoffset 0.4s ease-out ${(delay ?? 0)}ms`,
}}
ref={(el) => {
if (!el) return;
const len = el.getTotalLength();
el.style.strokeDasharray = String(len);
el.style.strokeDashoffset = String(len);
requestAnimationFrame(() => {
el.style.strokeDashoffset = '0';
});
setTimeout(() => {
el.style.transition = 'none';
el.style.strokeDasharray = '';
el.style.strokeDashoffset = '';
el.style.animation = '';
}, 500 + (delay ?? 0));
}}
/>
);
}
return <path d={d} className={cls} markerEnd={marker} />;
}
interface FlowBadgeProps {
x: number; y: number;
label: string;
type: string;
animate?: boolean;
delay?: number;
}
/** 연결선 위 뱃지 (HTML) */
export function FlowBadge({ x, y, label, type, animate, delay }: FlowBadgeProps) {
const cls = type === 'source' ? 'tpl-link' : type === 'auto' ? 'auto' : type === 'cond' ? 'cond' : '';
if (type === 'cond') {
const parts = label.split('→');
const condText = (parts[0] || '조건').trim();
const actionText = (parts[1] || '실행').trim();
return (
<div
className={`ctrl-badge cond`}
style={{
left: x, top: y,
opacity: animate ? 0 : 1,
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
}}
ref={(el) => {
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
}}
>
<div className="cb-head"><div className="cb-icon"></div> </div>
<div className="cb-cond">{condText}</div>
<div className="cb-paths">
<span className="cb-yes">Yes {actionText}</span>
<span className="cb-no">No </span>
</div>
</div>
);
}
return (
<div
className={`ctrl-badge ${cls}`}
style={{
left: x, top: y,
opacity: animate ? 0 : 1,
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
}}
ref={(el) => {
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
}}
>
{label}
</div>
);
}