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 후속 노트
174 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|