162 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|