import type { Connection, Edge, Node } from '@xyflow/react'; import type { LogicNode, ConditionDSL } from './strategyTypes'; import { nodeToText, type DefType } from './strategyEditorShared'; import { formatIndicatorPeriodLabel } from './strategyIndicatorPeriods'; import { START_NODE_ID, defaultStartMeta, getStartCandleTypes, isStartNodeId, normalizeStartCandleType, STRATEGY_CANDLE_TYPES, type StartNodeMeta, } from './strategyStartNodes'; export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES }; export type { StartNodeMeta }; const H_GAP = 280; const V_GAP = 130; export type HandleSide = 'top' | 'right' | 'bottom' | 'left'; const OPPOSE_SIDE: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left', }; export type StrategyFlowNodeData = { logicNode?: LogicNode; label?: string; def?: DefType; signalTab?: 'buy' | 'sell'; selected?: boolean; /** START 노드 — 조건 판별 시간봉 (표시용 대표) */ candleType?: string; candleTypes?: string[]; onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void; onDeleteStart?: (startId: string) => void; onDelete?: (id: string) => void; onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void; /** Stoch 과열×보조 복합 — 보조지표 변경 */ onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void; /** 9·20 신고가/신저가 복합 — 필터 강도 변경 */ onUpdatePriceExtremeFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; /** 33변곡 EMA+채널 복합 — 필터 강도 변경 */ onUpdateInflection33FilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; /** 추천 안정형 전략 — 필터 강도 변경 */ onUpdateStableStrategyFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; /** AND ↔ OR 전환 */ onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void; onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void; onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void; onDragLeaveTarget?: (targetId: string) => void; dragOver?: boolean; activeSourceSide?: HandleSide | null; /** 논리 트리상 자식을 가질 수 있으면 true (START·AND·OR·빈 NOT) */ canSourceConnect?: boolean; /** 논리 트리상 부모를 가질 수 있으면 true (조건·연산자, START 제외) */ canTargetConnect?: boolean; /** 전략 트리 미연결 — 편집기에만 표시, 수식·저장 제외 */ isOrphan?: boolean; }; export function isOrphanNode(orphans: LogicNode[], id: string): boolean { return orphans.some(o => o.id === id); } export function findLogicNode( root: LogicNode | null, orphans: LogicNode[], id: string, extraRoots: Record = {}, ): LogicNode | null { if (isStartNodeId(id)) return null; const inTree = findNodeInTree(root, id); if (inTree) return inTree; for (const extraRoot of Object.values(extraRoots)) { const hit = findNodeInTree(extraRoot, id); if (hit) return hit; } return orphans.find(o => o.id === id) ?? null; } /** 팔레트 드롭 시 트리에 즉시 연결되는 대상(START·트리 내 노드) */ export function isPaletteConnectTarget( anchorId: string, root: LogicNode | null, orphans: LogicNode[], ): boolean { if (isOrphanNode(orphans, anchorId)) return false; if (isStartNodeId(anchorId)) return true; if (!root) return false; return !!findNodeInTree(root, anchorId); } /** 노드별 연결 가능 역할 (DSL 트리 규칙) */ export function getNodeConnectionCaps( nodeId: string, logicNode: LogicNode | undefined, ): { canSource: boolean; canTarget: boolean } { if (nodeId === START_NODE_ID || isStartNodeId(nodeId)) return { canSource: true, canTarget: false }; if (!logicNode) return { canSource: false, canTarget: false }; switch (logicNode.type) { case 'CONDITION': return { canSource: false, canTarget: true }; case 'NOT': return { canSource: (logicNode.children?.length ?? 0) < 1, canTarget: true }; case 'AND': case 'OR': return { canSource: true, canTarget: true }; default: return { canSource: false, canTarget: false }; } } /** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */ export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean { if (isStartNodeId(anchorId)) return true; if (!root) return false; const node = findNodeInTree(root, anchorId); if (!node || node.type === 'CONDITION') return false; return getNodeConnectionCaps(anchorId, node).canSource; } /** * DSL 부모→자식 방향으로 연결 가능한지 (source=부모, target=자식). * React Flow Loose 모드에서는 conn.source/target이 드래그 방향에 따라 뒤집힐 수 있음. */ export function isValidStrategyConnectionDirected( parentId: string, childId: string, root: LogicNode | null, orphans: LogicNode[] = [], extraRoots: Record = {}, ): boolean { if (!parentId || !childId || parentId === childId) return false; if (isStartNodeId(childId)) return false; const childOrphan = orphans.find(o => o.id === childId); const parentOrphan = orphans.find(o => o.id === parentId); if (childOrphan) { if (isStartNodeId(parentId)) return true; const parentNode = findNodeInTree(root, parentId) ?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean) ?? null; if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false; return true; } if (parentOrphan) { if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false; const childNode = findNodeInTree(root, childId) ?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean) ?? null; if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false; if (root && isDescendant(root, childId, parentId)) return false; for (const er of Object.values(extraRoots)) { if (er && isDescendant(er, childId, parentId)) return false; } return true; } if (!root && Object.values(extraRoots).every(r => !r)) return false; const parentNode = isStartNodeId(parentId) ? null : findNodeInTree(root, parentId) ?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean) ?? null; const childNode = findNodeInTree(root, childId) ?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean) ?? null; if (!isStartNodeId(parentId) && !parentNode) return false; if (!childNode) return false; const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined); const childCaps = getNodeConnectionCaps(childId, childNode); if (!parentCaps.canSource || !childCaps.canTarget) return false; if (!isStartNodeId(parentId)) { if (root && isDescendant(root, childId, parentId)) return false; for (const er of Object.values(extraRoots)) { if (er && isDescendant(er, childId, parentId)) return false; } } return true; } /** 수동 연결·재연결 — 드래그 방향과 무관하게 부모→자식이 성립하면 유효 */ export function isValidStrategyConnection( conn: Connection, root: LogicNode | null, orphans: LogicNode[] = [], extraRoots: Record = {}, ): boolean { const { source, target } = conn; if (!source || !target) return false; return ( isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots) || isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots) ); } export function resolveStrategyConnection( conn: Connection, root: LogicNode | null, orphans: LogicNode[] = [], extraRoots: Record = {}, ): { parentId: string; childId: string } | null { const { source, target } = conn; if (!source || !target) return null; if (isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots)) { return { parentId: source, childId: target }; } if (isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots)) { return { parentId: target, childId: source }; } return null; } export function getNodeDimensions(nodeId: string): { w: number; h: number } { return isStartNodeId(nodeId) ? { w: FLOW_START_W, h: FLOW_START_H } : { w: FLOW_NODE_W, h: FLOW_NODE_H }; } /** 커서/드롭 위치가 노드 중심 기준 어느 방향인지 */ export function pickSideFromPoint( flowPos: { x: number; y: number }, nodePos: { x: number; y: number }, dim: { w: number; h: number }, ): HandleSide { const cx = nodePos.x + dim.w / 2; const cy = nodePos.y + dim.h / 2; const dx = flowPos.x - cx; const dy = flowPos.y - cy; if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left'; return dy >= 0 ? 'bottom' : 'top'; } /** 드롭이 앵커의 좌/우/상/하 쪽이면 해당 면 연결점 활성화 */ export function connectionSidesFromDrop( anchorPos: { x: number; y: number }, anchorDim: { w: number; h: number }, dropPos: { x: number; y: number }, ): { sourceSide: HandleSide; targetSide: HandleSide; sourceHandle: string; targetHandle: string } { const sourceSide = pickSideFromPoint(dropPos, anchorPos, anchorDim); const targetSide = OPPOSE_SIDE[sourceSide]; return { sourceSide, targetSide, sourceHandle: `s-${sourceSide}`, targetHandle: `t-${targetSide}`, }; } export function spawnPositionNearAnchor( anchorPos: { x: number; y: number }, anchorDim: { w: number; h: number }, side: HandleSide, ): { x: number; y: number } { const gap = 48; const child = { w: FLOW_NODE_W, h: FLOW_NODE_H }; switch (side) { case 'right': return { x: anchorPos.x + anchorDim.w + gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 }; case 'left': return { x: anchorPos.x - child.w - gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 }; case 'bottom': return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y + anchorDim.h + gap }; case 'top': return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y - child.h - gap }; } } export function findNodeAtFlowPosition( flowPos: { x: number; y: number }, nodes: { id: string; position: { x: number; y: number } }[], padding = 16, ): { id: string; position: { x: number; y: number } } | null { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; const dim = getNodeDimensions(n.id); if ( flowPos.x >= n.position.x - padding && flowPos.x <= n.position.x + dim.w + padding && flowPos.y >= n.position.y - padding && flowPos.y <= n.position.y + dim.h + padding ) { return n; } } return null; } /** 미연결 고아 노드를 드래그해 다른 노드 위에 올렸을 때 연결 방향 해석 */ export function resolveOrphanDragConnection( orphanId: string, orphanPos: { x: number; y: number }, nodes: { id: string; position: { x: number; y: number } }[], root: LogicNode | null, orphans: LogicNode[], extraRoots: Record = {}, ): { parentId: string; childId: string } | null { const dim = getNodeDimensions(orphanId); const center = { x: orphanPos.x + dim.w / 2, y: orphanPos.y + dim.h / 2, }; const hit = findNodeAtFlowPosition( center, nodes.filter(n => n.id !== orphanId), ); if (!hit) return null; if (isOrphanNode(orphans, hit.id)) return null; return resolveStrategyConnection( { source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null }, root, orphans, extraRoots, ) ?? resolveStrategyConnection( { source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null }, root, orphans, extraRoots, ); } /** 드래그 연결 시 부모→자식 핸들 방향 */ export function buildConnectionFromPositions( parentId: string, childId: string, positions: Map, ): Connection { const parentPos = positions.get(parentId) ?? { x: 0, y: 0 }; const childPos = positions.get(childId) ?? { x: 0, y: 0 }; const parentDim = getNodeDimensions(parentId); const childDim = getNodeDimensions(childId); const childCenter = { x: childPos.x + childDim.w / 2, y: childPos.y + childDim.h / 2, }; const { sourceHandle, targetHandle } = connectionSidesFromDrop(parentPos, parentDim, childCenter); return { source: parentId, target: childId, sourceHandle, targetHandle, }; } export type EdgeHandleBinding = { sourceHandle?: string | null; targetHandle?: string | null; /** smooth-step 경로 굽힘 중심 (중간 영역 드래그) */ centerX?: number | null; centerY?: number | null; manual?: boolean; manualSource?: boolean; manualTarget?: boolean; manualCenter?: boolean; }; export const FLOW_NODE_W = 200; export const FLOW_NODE_H = 72; export const FLOW_START_W = 200; export const FLOW_START_H = 78; function nodeCenter( id: string, positions: Map, ): { x: number; y: number } { const p = positions.get(id) ?? { x: 0, y: 0 }; const w = isStartNodeId(id) ? FLOW_START_W : FLOW_NODE_W; const h = isStartNodeId(id) ? FLOW_START_H : FLOW_NODE_H; return { x: p.x + w / 2, y: p.y + h / 2 }; } /** 두 노드 상대 위치에 따라 최적 연결 방향(핸들) 선택 */ export function pickHandleSides( sourceId: string, targetId: string, positions: Map, ): { sourceHandle: string; targetHandle: string } { const s = nodeCenter(sourceId, positions); const t = nodeCenter(targetId, positions); const dx = t.x - s.x; const dy = t.y - s.y; if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? { sourceHandle: 's-right', targetHandle: 't-left' } : { sourceHandle: 's-left', targetHandle: 't-right' }; } return dy >= 0 ? { sourceHandle: 's-bottom', targetHandle: 't-top' } : { sourceHandle: 's-top', targetHandle: 't-bottom' }; } /** 경로 굽힘 중심 기준 — 양쪽 노드에 가장 자연스러운 핸들 */ export function pickHandlesFromCenter( sourceId: string, targetId: string, center: { x: number; y: number }, positions: Map, ): { sourceHandle: string; targetHandle: string } { const sourcePos = positions.get(sourceId) ?? { x: 0, y: 0 }; const targetPos = positions.get(targetId) ?? { x: 0, y: 0 }; const sourceDim = getNodeDimensions(sourceId); const targetDim = getNodeDimensions(targetId); const sourceSide = pickSideFromPoint(center, sourcePos, sourceDim); const targetSide = pickSideFromPoint(center, targetPos, targetDim); return { sourceHandle: `s-${sourceSide}`, targetHandle: `t-${targetSide}`, }; } export function applyEdgeHandles( edges: Edge[], positions: Map, saved: Map, ): Edge[] { return edges.map(edge => { const binding = saved.get(edge.id) ?? {}; const picked = pickHandleSides(edge.source, edge.target, positions); let sourceHandle = picked.sourceHandle; let targetHandle = picked.targetHandle; if ( binding.manualCenter && binding.centerX != null && binding.centerY != null ) { const natural = pickHandlesFromCenter( edge.source, edge.target, { x: binding.centerX, y: binding.centerY }, positions, ); if (!binding.manualSource) sourceHandle = natural.sourceHandle; else sourceHandle = binding.sourceHandle ?? picked.sourceHandle; if (!binding.manualTarget) targetHandle = natural.targetHandle; else targetHandle = binding.targetHandle ?? picked.targetHandle; } else { if (binding.manualSource) sourceHandle = binding.sourceHandle ?? picked.sourceHandle; if (binding.manualTarget) targetHandle = binding.targetHandle ?? picked.targetHandle; } const nextBinding: EdgeHandleBinding = { ...binding, sourceHandle, targetHandle, }; saved.set(edge.id, nextBinding); return { ...edge, sourceHandle, targetHandle, data: { ...(edge.data as object), binding: nextBinding }, }; }); } function layoutTree( node: LogicNode, parentId: string, depth: number, siblingIndex: number, siblingCount: number, nodes: Node[], edges: Edge[], positions: Map, def: DefType, signalTab: 'buy' | 'sell', callbacks: Pick< StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' >, dragOverId: string | null, ): void { const yBase = 220; const y = yBase + (siblingIndex - (siblingCount - 1) / 2) * V_GAP; const pos = positions.get(node.id) ?? { x: 80 + depth * H_GAP, y }; const flowType = node.type === 'CONDITION' ? 'condition' : 'logic'; const caps = getNodeConnectionCaps(node.id, node); nodes.push({ id: node.id, type: flowType, position: pos, data: { logicNode: node, label: nodeToText(node, def), def, signalTab, onDelete: callbacks.onDelete, onUpdateCondition: callbacks.onUpdateCondition, onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel, onChangeLogicGateType: callbacks.onChangeLogicGateType, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, onDragLeaveTarget: callbacks.onDragLeaveTarget, dragOver: dragOverId === node.id, canSourceConnect: caps.canSource, canTargetConnect: caps.canTarget, } satisfies StrategyFlowNodeData, draggable: true, }); edges.push({ id: `${parentId}-${node.id}`, source: parentId, target: node.id, type: 'strategy', animated: node.type === 'CONDITION', className: 'se-flow-edge', }); const children = node.children ?? []; children.forEach((child, i) => { layoutTree(child, node.id, depth + 1, i, children.length, nodes, edges, positions, def, signalTab, callbacks, dragOverId); }); } const AUTO_LAYOUT_ORIGIN_X = 48; const AUTO_LAYOUT_ORIGIN_Y = 48; const AUTO_LAYOUT_SECTION_GAP = 72; const AUTO_LAYOUT_START_TREE_GAP = 72; const AUTO_LAYOUT_ORPHAN_GAP = 100; function measureSubtreeHeight(node: LogicNode): number { const children = node.children ?? []; if (children.length === 0) return FLOW_NODE_H; const childHeights = children.map(measureSubtreeHeight); return childHeights.reduce((sum, h) => sum + h, 0) + (children.length - 1) * V_GAP; } function placeSubtree( node: LogicNode, x: number, centerY: number, positions: Map, ): void { const children = node.children ?? []; if (children.length === 0) { positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 }); return; } const childHeights = children.map(measureSubtreeHeight); const totalHeight = childHeights.reduce((a, b) => a + b, 0) + (children.length - 1) * V_GAP; let y = centerY - totalHeight / 2; children.forEach((child, i) => { const ch = childHeights[i]; placeSubtree(child, x + H_GAP, y + ch / 2, positions); y += ch + V_GAP; }); positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 }); } function maxLayoutX(positions: Map): number { let max = 0; for (const [id, pos] of positions) { const dim = getNodeDimensions(id); max = Math.max(max, pos.x + dim.w); } return max; } /** 그래프 캔버스 자동 정렬 — START별 트리를 좌→우, 형제는 위→아래 배치 */ export function computeAutoFlowLayout( root: LogicNode | null, extraStartIds: string[], extraRoots: Record, orphans: LogicNode[], ): Record { const positions = new Map(); let yCursor = AUTO_LAYOUT_ORIGIN_Y; const placeStartSection = (startId: string, branchRoot: LogicNode | null) => { const startDim = getNodeDimensions(startId); const branchHeight = branchRoot ? measureSubtreeHeight(branchRoot) : 0; const sectionHeight = Math.max(startDim.h, branchHeight); const centerY = yCursor + sectionHeight / 2; positions.set(startId, { x: AUTO_LAYOUT_ORIGIN_X, y: centerY - startDim.h / 2, }); if (branchRoot) { placeSubtree( branchRoot, AUTO_LAYOUT_ORIGIN_X + startDim.w + AUTO_LAYOUT_START_TREE_GAP, centerY, positions, ); } yCursor += sectionHeight + AUTO_LAYOUT_SECTION_GAP; }; placeStartSection(START_NODE_ID, root); for (const startId of extraStartIds) { placeStartSection(startId, extraRoots[startId] ?? null); } if (orphans.length > 0) { let orphanY = AUTO_LAYOUT_ORIGIN_Y; const orphanX = (positions.size > 0 ? maxLayoutX(positions) : AUTO_LAYOUT_ORIGIN_X) + AUTO_LAYOUT_ORPHAN_GAP; for (const orphan of orphans) { const h = measureSubtreeHeight(orphan); positions.set(orphan.id, { x: orphanX, y: orphanY }); orphanY += h + V_GAP; } } return Object.fromEntries(positions); } function appendOrphanFlowNodes( orphans: LogicNode[], nodes: Node[], positions: Map, def: DefType, signalTab: 'buy' | 'sell', callbacks: Pick< StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' >, ): void { let orphanY = 80; for (const node of orphans) { const pos = positions.get(node.id) ?? { x: 40, y: orphanY }; orphanY += V_GAP; const flowType = node.type === 'CONDITION' ? 'condition' : 'logic'; const caps = getNodeConnectionCaps(node.id, node); nodes.push({ id: node.id, type: flowType, position: pos, className: 'se-flow-node-orphan', data: { logicNode: node, label: nodeToText(node, def), def, signalTab, onDelete: callbacks.onDelete, onUpdateCondition: callbacks.onUpdateCondition, onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel, onChangeLogicGateType: callbacks.onChangeLogicGateType, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, onDragLeaveTarget: callbacks.onDragLeaveTarget, canSourceConnect: caps.canSource, canTargetConnect: caps.canTarget, isOrphan: true, } satisfies StrategyFlowNodeData, draggable: true, selectable: true, }); } } export function logicNodeToFlow( root: LogicNode | null, def: DefType, signalTab: 'buy' | 'sell', positions: Map, callbacks: Pick< StrategyFlowNodeData, | 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' | 'onStartCandleTypesChange' | 'onDeleteStart' | 'onChangeLogicGateType' >, dragOverId: string | null = null, orphans: LogicNode[] = [], startMeta: Record = defaultStartMeta(), extraStartIds: string[] = [], extraRoots: Record = {}, ): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = []; const edges: Edge[] = []; const appendStart = (startId: string, branchRoot: LogicNode | null, defaultY: number) => { const candleTypes = getStartCandleTypes(startMeta[startId]); nodes.push({ id: startId, type: 'start', position: positions.get(startId) ?? { x: 0, y: defaultY }, data: { label: 'START', signalTab, candleType: candleTypes[0], candleTypes, onStartCandleTypesChange: callbacks.onStartCandleTypesChange, onDeleteStart: startId === START_NODE_ID ? undefined : callbacks.onDeleteStart, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, onDragLeaveTarget: callbacks.onDragLeaveTarget, canSourceConnect: true, canTargetConnect: false, } satisfies StrategyFlowNodeData, draggable: true, selectable: true, }); if (branchRoot) { layoutTree( branchRoot, startId, 1, 0, 1, nodes, edges, positions, def, signalTab, callbacks, dragOverId, ); } }; appendStart(START_NODE_ID, root, 220); extraStartIds.forEach((startId, index) => { appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140); }); appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks); return { nodes, edges }; } export function getIndicatorPeriodLabel(indicator: string, def: DefType): string { return formatIndicatorPeriodLabel(indicator, def); } /** 트리에서 노드 추출 (부모에서 제거 후 반환) */ export function extractNode(root: LogicNode, id: string): { tree: LogicNode | null; node: LogicNode | null } { if (root.id === id) return { tree: null, node: root }; if (!root.children?.length) return { tree: root, node: null }; for (let i = 0; i < root.children.length; i++) { const child = root.children[i]; if (child.id === id) { return { tree: { ...root, children: root.children.filter(c => c.id !== id) }, node: child, }; } const sub = extractNode(child, id); if (sub.node) { const newChildren = root.children.map(c => (c.id === child.id ? sub.tree! : c)).filter(Boolean) as LogicNode[]; return { tree: { ...root, children: newChildren }, node: sub.node }; } } return { tree: root, node: null }; } export function findNodeInTree(root: LogicNode | null, id: string): LogicNode | null { if (!root) return null; if (root.id === id) return root; for (const c of root.children ?? []) { const hit = findNodeInTree(c, id); if (hit) return hit; } return null; } /** 트리에서 nodeId의 직계 부모 ID (루트의 부모는 START) */ export function findParentIdInTree( root: LogicNode, nodeId: string, parentId: string = START_NODE_ID, ): string | null { if (root.id === nodeId) return parentId; for (const c of root.children ?? []) { const hit = findParentIdInTree(c, nodeId, root.id); if (hit) return hit; } return null; } /** 연결 해제 시 미연결 목록으로 펼침 — Logic Expression·캔버스 모두 트리에서 제외 */ export function subtreeToFlatOrphans(node: LogicNode): LogicNode[] { switch (node.type) { case 'CONDITION': return [{ ...node, children: undefined }]; case 'NOT': { const inner = node.children?.[0]; return inner ? subtreeToFlatOrphans(inner) : []; } case 'AND': case 'OR': { const kids = node.children ?? []; if (kids.length === 0) return []; return kids.flatMap(subtreeToFlatOrphans); } default: return []; } } /** 자식이 없는 AND/OR/NOT 제거 — 수식에서 (빈 AND) 등 방지 */ export function pruneEmptyGates(root: LogicNode | null): LogicNode | null { if (!root) return null; if (root.type === 'CONDITION') return root; const prunedChildren = (root.children ?? []) .map(c => pruneEmptyGates(c)) .filter((c): c is LogicNode => c != null); if (root.type === 'NOT') { if (prunedChildren.length === 0) return null; return { ...root, children: [prunedChildren[0]] }; } if (root.type === 'AND' || root.type === 'OR') { if (prunedChildren.length === 0) return null; return { ...root, children: prunedChildren }; } return root; } function mergeOrphans(existing: LogicNode[], added: LogicNode[]): LogicNode[] { const seen = new Set(existing.map(o => o.id)); const next = [...existing]; for (const o of added) { if (!seen.has(o.id)) { seen.add(o.id); next.push(o); } } return next; } function resolveDisconnectEndpoints( sourceId: string, targetId: string, root: LogicNode | null, orphans: LogicNode[], extraRoots: Record, ): { parentId: string; childId: string } | null { const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null }; const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots); if (resolved) return resolved; if (isStartNodeId(sourceId)) { const branchRoot = sourceId === START_NODE_ID ? root : extraRoots[sourceId] ?? null; if (branchRoot?.id === targetId) { return { parentId: sourceId, childId: targetId }; } } if (root) { const parentInTree = findParentIdInTree(root, targetId); if (parentInTree === sourceId) { return { parentId: sourceId, childId: targetId }; } } for (const [startId, branchRoot] of Object.entries(extraRoots)) { if (!branchRoot) continue; const parentInTree = findParentIdInTree(branchRoot, targetId, startId); if (parentInTree === sourceId) { return { parentId: sourceId, childId: targetId }; } } return null; } export function isDescendant(root: LogicNode, ancestorId: string, nodeId: string): boolean { const ancestor = findNodeInTree(root, ancestorId); if (!ancestor) return false; return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId; } export function disconnectTreeEdge( sourceId: string, targetId: string, root: LogicNode | null, orphans: LogicNode[], extraRoots: Record = {}, ): { root: LogicNode | null; orphans: LogicNode[]; extraRoots: Record; } { const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans, extraRoots); if (!endpoints) return { root, orphans, extraRoots }; const { parentId, childId } = endpoints; if (isStartNodeId(childId)) return { root, orphans, extraRoots }; const inPrimary = root && (root.id === childId || !!findNodeInTree(root, childId)); const inExtraStartId = Object.entries(extraRoots).find(([, branch]) => branch && (branch.id === childId || !!findNodeInTree(branch, childId)), )?.[0]; if (!inPrimary && !inExtraStartId) return { root, orphans, extraRoots }; if (isStartNodeId(parentId)) { if (parentId === START_NODE_ID && root?.id === childId) { return { root: null, orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)), extraRoots, }; } const branchRoot = extraRoots[parentId] ?? null; if (branchRoot?.id === childId) { const nextExtra = { ...extraRoots, [parentId]: null }; return { root, orphans: mergeOrphans(orphans, subtreeToFlatOrphans(branchRoot)), extraRoots: nextExtra, }; } } const extractFrom = inPrimary ? root! : extraRoots[inExtraStartId!]!; const { tree, node } = extractNode(extractFrom, childId); if (!node) return { root, orphans, extraRoots }; if (inPrimary) { return { root: pruneEmptyGates(tree), orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)), extraRoots, }; } return { root, orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)), extraRoots: { ...extraRoots, [inExtraStartId!]: pruneEmptyGates(tree), }, }; }