Files
invyone/frontend/components/control/ConnectionLine.tsx
T
2026-04-10 13:33:37 +09:00

162 lines
5.3 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>
);
}
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${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>
);
}