import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ReactFlow, Background, Controls, MiniMap, Panel, useNodesState, useEdgesState, useReactFlow, ConnectionMode, SelectionMode, type Connection, type Edge, type Node, type OnNodesChange, type OnSelectionChangeFunc, type ReactFlowInstance, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type { Theme } from '../../types/index'; import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes'; import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize'; import { addChild, deleteNode, mergeAtRoot, makeNode, makeNodeOptionsFromPalette, updateNode, type DefType, } from '../../utils/strategyEditorShared'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { isPaletteHtmlDrag, isPaletteHtmlDragActive, getLastHtmlPaletteDragClientXY, isPaletteDragSessionActive, markPaletteHtmlDragEnd, needsPointerPaletteDrag, readPaletteHtmlDragData, setHtmlPaletteDropRouter, setPaletteDragDropHandler, subscribePaletteDrag, type PaletteDragEvent, } from '../../utils/paletteDragSession'; import { isOverStrategyBuilder, resolvePaletteDropClientPoint, } from '../../utils/flowScreenProjection'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { setPriceExtremePairFilterLevel, type PriceExtremeFilterLevel, } from '../../utils/priceExtremeBreakoutPair'; import { setInflection33PairFilterLevel, type Inflection33FilterLevel, } from '../../utils/inflection33Strategy'; import { setStableStrategyPairFilterLevel, type StableStrategyFilterLevel, } from '../../utils/stableStrategyPairs'; import { START_NODE_ID, applyEdgeHandles, buildConnectionFromPositions, canAcceptPaletteAsParent, connectionSidesFromDrop, disconnectTreeEdge, extractNode, findNodeAtFlowPosition, findNodeInTree, FLOW_NODE_H, FLOW_NODE_W, getNodeDimensions, isDescendant, isOrphanNode, isPaletteConnectTarget, isStartNodeId, isValidStrategyConnection, resolveStrategyConnection, resolveOrphanDragConnection, logicNodeToFlow, computeAutoFlowLayout, spawnPositionNearAnchor, subtreeToFlatOrphans, type EdgeHandleBinding, type StrategyFlowNodeData, } from '../../utils/strategyFlowLayout'; import { createStartNodeId, defaultStartMeta, DEFAULT_START_CANDLE, normalizeStartCandleType, type StartNodeMeta, } from '../../utils/strategyStartNodes'; import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes'; import { StrategyFlowEdge } from './StrategyFlowEdge'; import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks'; import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton'; import { getMinimapColors } from './minimapTheme'; import { loadCanvasInteractionMode, saveCanvasInteractionMode, type StrategyCanvasInteractionMode, } from '../../utils/strategyCanvasInteractionStorage'; import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage'; export type FlowLayoutSeed = { seedKey: string; positions: Record; edgeHandles: Record; }; const nodeTypes = { start: StartNode, logic: LogicGateNode, condition: ConditionFlowNode, }; const edgeTypes = { strategy: StrategyFlowEdge, }; interface Props { theme: Theme; root: LogicNode | null; orphans: LogicNode[]; onOrphansChange: (nodes: LogicNode[]) => void; def: DefType; signalTab: 'buy' | 'sell'; onChange: (root: LogicNode | null) => void; selectedNodeId: string | null; onSelectNode: (id: string | null) => void; layoutSeed: FlowLayoutSeed; onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void; startMeta?: Record; extraStartIds?: string[]; extraRoots?: Record; onStartMetaChange?: (meta: Record) => void; /** START 체크박스 — startMeta + 조건 트리 분봉 동기화 */ onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void; onExtraStartIdsChange?: (ids: string[]) => void; onExtraRootsChange?: (roots: Record) => void; } function collectTreeIds(node: LogicNode | null): string[] { if (!node) return []; const ids = [node.id]; for (const c of node.children ?? []) ids.push(...collectTreeIds(c)); return ids; } function saveEdgeHandles( handles: Map, edgeId: string, conn: Pick, lockHandles = false, ) { handles.set(edgeId, { sourceHandle: conn.sourceHandle, targetHandle: conn.targetHandle, manual: lockHandles, manualSource: lockHandles, manualTarget: lockHandles, }); } function applyTreeConnection( sourceId: string, targetId: string, root: LogicNode | null, extraRoots: Record, onChange: (root: LogicNode | null) => void, onExtraRootsChange: (roots: Record) => void, ) { const promoteToStart = (startId: string, node: LogicNode, remainder: LogicNode | null) => { if (startId === START_NODE_ID) { onChange(remainder ? mergeAtRoot(node, remainder, false) : node); return; } const branch = extraRoots[startId] ?? null; onExtraRootsChange({ ...extraRoots, [startId]: branch ? mergeAtRoot(branch, node, false) : node, }); }; if (isStartNodeId(sourceId)) { if (root && (root.id === targetId || findNodeInTree(root, targetId))) { const { tree, node } = extractNode(root, targetId); if (!node) return; onChange(tree); promoteToStart(sourceId, node, tree); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (!branch || !findNodeInTree(branch, targetId)) continue; const { tree, node } = extractNode(branch, targetId); if (!node) return; onExtraRootsChange({ ...extraRoots, [sid]: tree }); promoteToStart(sourceId, node, null); return; } return; } if (!root) return; const sourceNode = findNodeInTree(root, sourceId) ?? Object.values(extraRoots).map(br => findNodeInTree(br, sourceId)).find(Boolean); if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return; if (findNodeInTree(root, targetId)) { if (isDescendant(root, targetId, sourceId)) return; const { tree: afterRemove, node: moved } = extractNode(root, targetId); if (!moved) return; onChange(addChild(afterRemove ?? root, sourceId, moved)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (!branch || !findNodeInTree(branch, targetId)) continue; if (isDescendant(branch, targetId, sourceId)) return; const { tree, node: moved } = extractNode(branch, targetId); if (!moved) return; onExtraRootsChange({ ...extraRoots, [sid]: tree }); if (findNodeInTree(root, sourceId)) { onChange(addChild(root, sourceId, moved)); } else { const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, sourceId)); if (parentBranch) { const [psid, pbr] = parentBranch; onExtraRootsChange({ ...extraRoots, [sid]: tree, [psid]: addChild(pbr!, sourceId, moved), }); } } return; } } function pruneEdgeHandles(handles: Map, edges: Edge[]) { const ids = new Set(edges.map(e => e.id)); for (const id of handles.keys()) { if (!ids.has(id)) handles.delete(id); } } function connectOrphanIntoTree( conn: Connection, orphan: LogicNode, root: LogicNode | null, extraRoots: Record, onChange: (root: LogicNode | null) => void, onExtraRootsChange: (roots: Record) => void, ): void { const sourceId = conn.source!; if (isStartNodeId(sourceId)) { if (sourceId === START_NODE_ID) { onChange(root ? mergeAtRoot(root, orphan, false) : orphan); return; } const branch = extraRoots[sourceId] ?? null; onExtraRootsChange({ ...extraRoots, [sourceId]: branch ? mergeAtRoot(branch, orphan, false) : orphan, }); return; } if (!root) return; onChange(addChild(root, sourceId, orphan)); } function connectOrphanAsParent( conn: Connection, orphan: LogicNode, root: LogicNode | null, onChange: (root: LogicNode | null) => void, ): void { if (!root) return; const { tree, node: moved } = extractNode(root, conn.target!); if (!moved) return; const isOp = ['AND', 'OR', 'NOT'].includes(orphan.type); const combined = { ...orphan, children: [...(orphan.children ?? []), moved] }; onChange(tree ? mergeAtRoot(tree, combined, isOp) : combined); } function StrategyEditorCanvasInner({ theme, root, orphans, onOrphansChange, def, signalTab, onChange, selectedNodeId, onSelectNode, layoutSeed, onLayoutChange, startMeta = defaultStartMeta(), extraStartIds = [], extraRoots = {}, onStartMetaChange, onStartCandleTypesChange, onExtraStartIdsChange, onExtraRootsChange, }: Props) { const { fitView } = useReactFlow(); const canvasWrapRef = useRef(null); const reactFlowRef = useRef(null); const positionsRef = useRef(new Map()); const edgeHandlesRef = useRef(new Map()); const structureKeyRef = useRef(null); const orphanKeyRef = useRef(null); const layoutEmitTimerRef = useRef(null); const [interactionMode, setInteractionMode] = useState( () => loadCanvasInteractionMode(), ); const handleInteractionModeChange = useCallback((mode: StrategyCanvasInteractionMode) => { setInteractionMode(mode); saveCanvasInteractionMode(mode); }, []); const [nodes, setNodes, onNodesChangeBase] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const nodesRef = useRef(nodes); const edgesRef = useRef(edges); nodesRef.current = nodes; edgesRef.current = edges; const treeStructureKey = useMemo(() => { const parts = [collectTreeIds(root).join('|')]; for (const sid of extraStartIds) parts.push(sid); for (const br of Object.values(extraRoots)) parts.push(collectTreeIds(br).join('|')); return parts.join('::'); }, [root, extraStartIds, extraRoots]); const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]); /** START에서 연결된 트리 전체 (미연결 고아 제외) */ const treeLinkedIds = useMemo(() => { const ids = new Set([START_NODE_ID, ...extraStartIds]); for (const id of collectTreeIds(root)) ids.add(id); for (const br of Object.values(extraRoots)) { for (const id of collectTreeIds(br)) ids.add(id); } return ids; }, [root, extraStartIds, extraRoots]); const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]); const minimapNodeColor = useCallback((node: Node) => { if (isStartNodeId(node.id)) return minimapColors.start; if (node.type === 'condition') return minimapColors.condition; return minimapColors.logic; }, [minimapColors]); const minimapNodeStrokeColor = useCallback((node: Node) => { if (isStartNodeId(node.id)) return minimapColors.start; return minimapColors.stroke; }, [minimapColors]); const signalTabRef = useRef(signalTab); signalTabRef.current = signalTab; const emitLayoutChange = useCallback((tab: 'buy' | 'sell') => { if (!onLayoutChange) return; onLayoutChange({ tab, positions: Object.fromEntries(positionsRef.current), edgeHandles: Object.fromEntries(edgeHandlesRef.current), }); }, [onLayoutChange]); const scheduleLayoutEmit = useCallback(() => { if (!onLayoutChange) return; const tabAtSchedule = signalTabRef.current; if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current); layoutEmitTimerRef.current = window.setTimeout(() => { layoutEmitTimerRef.current = null; emitLayoutChange(tabAtSchedule); }, 120); }, [onLayoutChange, emitLayoutChange]); useEffect(() => () => { if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current); }, []); useEffect(() => { positionsRef.current = new Map(Object.entries(layoutSeed.positions)); edgeHandlesRef.current = new Map(Object.entries(layoutSeed.edgeHandles)); }, [layoutSeed.edgeHandles, layoutSeed.positions, layoutSeed.seedKey]); useEffect(() => { layoutFlushRef.current = () => { if (layoutEmitTimerRef.current != null) { window.clearTimeout(layoutEmitTimerRef.current); layoutEmitTimerRef.current = null; } for (const n of nodesRef.current) { positionsRef.current.set(n.id, { x: n.position.x, y: n.position.y }); } emitLayoutChange(signalTabRef.current); }; return () => { layoutFlushRef.current = null; }; }, [emitLayoutChange]); const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { if (onStartCandleTypesChange) { onStartCandleTypesChange(startId, candleTypes); return; } const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE]; const normalized = types.map(normalizeStartCandleType); onStartMetaChange?.({ ...startMeta, [startId]: { candleTypes: normalized, candleType: normalized[0] }, }); }, [startMeta, onStartMetaChange, onStartCandleTypesChange]); const handleDeleteStart = useCallback((startId: string) => { if (startId === START_NODE_ID) return; const branch = extraRoots[startId]; if (branch) { onOrphansChange([...orphans, ...subtreeToFlatOrphans(branch)]); } onExtraStartIdsChange?.(extraStartIds.filter(id => id !== startId)); const nextMeta = { ...startMeta }; delete nextMeta[startId]; onStartMetaChange?.(nextMeta); const nextRoots = { ...extraRoots }; delete nextRoots[startId]; onExtraRootsChange?.(nextRoots); positionsRef.current.delete(startId); scheduleLayoutEmit(); }, [ extraRoots, extraStartIds, startMeta, orphans, onOrphansChange, onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit, ]); const addStartAt = useCallback((flowPos: { x: number; y: number }) => { const newId = createStartNodeId(); onExtraStartIdsChange?.([...extraStartIds, newId]); onStartMetaChange?.({ ...startMeta, [newId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE }, }); onExtraRootsChange?.({ ...extraRoots, [newId]: null }); positionsRef.current.set(newId, { x: flowPos.x - getNodeDimensions(newId).w / 2, y: flowPos.y - getNodeDimensions(newId).h / 2, }); scheduleLayoutEmit(); }, [ extraStartIds, extraRoots, startMeta, onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit, ]); const handleDelete = useCallback((id: string) => { if (isStartNodeId(id)) return; if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.filter(o => o.id !== id)); positionsRef.current.delete(id); if (selectedNodeId === id) onSelectNode(null); return; } if (root && (root.id === id || findNodeInTree(root, id))) { if (root.id === id) { onChange(null); } else { onChange(deleteNode(root, id)); } positionsRef.current.delete(id); if (selectedNodeId === id) onSelectNode(null); scheduleLayoutEmit(); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (!branch || !findNodeInTree(branch, id)) continue; if (branch.id === id) { onExtraRootsChange?.({ ...extraRoots, [sid]: null }); } else { onExtraRootsChange?.({ ...extraRoots, [sid]: deleteNode(branch, id) }); } positionsRef.current.delete(id); if (selectedNodeId === id) onSelectNode(null); scheduleLayoutEmit(); return; } }, [ root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange, onSelectNode, selectedNodeId, scheduleLayoutEmit, ]); const deleteSelectedNodes = useCallback(() => { const ids = nodesRef.current .filter(n => n.selected && !isStartNodeId(n.id)) .map(n => n.id); if (!ids.length) return; const orphanIds = new Set(ids.filter(id => isOrphanNode(orphans, id))); if (orphanIds.size) { onOrphansChange(orphans.filter(o => !orphanIds.has(o.id))); orphanIds.forEach(id => positionsRef.current.delete(id)); } const treeIds = ids.filter(id => !orphanIds.has(id)); if (treeIds.length && root) { let tree: LogicNode | null = root; for (const id of treeIds) { if (!tree) break; if (tree.id === id) { tree = null; positionsRef.current.delete(id); continue; } const next = deleteNode(tree, id); if (next !== tree) positionsRef.current.delete(id); tree = next; } onChange(tree); } onSelectNode(null); scheduleLayoutEmit(); }, [root, orphans, onChange, onOrphansChange, onSelectNode, scheduleLayoutEmit]); const clearDropPreview = useCallback(() => { setNodes(nds => nds.map(n => ({ ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined, }, }))); }, [setNodes]); const updateDropPreview = useCallback((anchorId: string, flowPos: { x: number; y: number }) => { const anchor = nodesRef.current.find(n => n.id === anchorId); if (!anchor) return; const canParent = canAcceptPaletteAsParent(anchorId, root); if (!canParent) { setNodes(nds => nds.map(n => ({ ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined }, }))); return; } const dim = getNodeDimensions(anchorId); const { sourceSide } = connectionSidesFromDrop(anchor.position, dim, flowPos); setNodes(nds => nds.map(n => ({ ...n, data: { ...n.data, dragOver: n.id === anchorId, activeSourceSide: n.id === anchorId ? sourceSide : undefined, }, }))); }, [setNodes, root]); const orphanDragTargetRef = useRef<{ parentId: string; childId: string } | null>(null); const updateOrphanConnectPreview = useCallback(( resolved: { parentId: string; childId: string }, draggedOrphanId: string, orphanPos: { x: number; y: number }, ) => { const { parentId, childId } = resolved; const nodesSnapshot = nodesRef.current; const parentNode = nodesSnapshot.find(n => n.id === parentId); const childNode = nodesSnapshot.find(n => n.id === childId); if (!parentNode || !childNode) return; const parentDim = getNodeDimensions(parentId); const childDim = getNodeDimensions(childId); const parentPos = parentId === draggedOrphanId ? orphanPos : parentNode.position; const childPos = childId === draggedOrphanId ? orphanPos : childNode.position; const childCenter = { x: childPos.x + childDim.w / 2, y: childPos.y + childDim.h / 2, }; const { sourceSide } = connectionSidesFromDrop(parentPos, parentDim, childCenter); setNodes(nds => nds.map(n => ({ ...n, data: { ...n.data, dragOver: n.id === parentId, activeSourceSide: n.id === parentId ? sourceSide : undefined, }, }))); }, [setNodes]); const resolvePaletteDropNode = useCallback(( data: { type: string; value: string; label: string; composite?: boolean }, ): LogicNode | null => { if (data.type === 'sidewaysFilter') { return buildSidewaysFilterNode(data.value, def); } return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); }, [signalTab, def]); const addOrphanAt = useCallback(( data: { type: string; value: string; label: string; composite?: boolean }, flowPos: { x: number; y: number }, ) => { const newNode = resolvePaletteDropNode(data); if (!newNode) return; positionsRef.current.set(newNode.id, { x: flowPos.x - FLOW_NODE_W / 2, y: flowPos.y - FLOW_NODE_H / 2, }); onOrphansChange([...orphans, newNode]); scheduleLayoutEmit(); }, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]); const applyDropWithAttach = useCallback(( anchorId: string, data: { type: string; value: string; label: string; composite?: boolean }, flowPos: { x: number; y: number }, ): boolean => { if (data.type === 'start') { addStartAt(flowPos); return true; } const newNode = resolvePaletteDropNode(data); if (!newNode) return false; const anchor = nodesRef.current.find(n => n.id === anchorId); if (!anchor) return false; const anchorDim = getNodeDimensions(anchorId); const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos); positionsRef.current.set(newNode.id, spawnPositionNearAnchor(anchor.position, anchorDim, sourceSide)); const saveAttachHandles = (parentId: string) => { saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle }); }; if (isStartNodeId(anchorId)) { if (anchorId === START_NODE_ID) { onChange(root ? mergeAtRoot(root, newNode, data.type === 'operator') : newNode); } else { const branch = extraRoots[anchorId] ?? null; onExtraRootsChange?.({ ...extraRoots, [anchorId]: branch ? mergeAtRoot(branch, newNode, data.type === 'operator') : newNode, }); } saveAttachHandles(anchorId); scheduleLayoutEmit(); return true; } const anchorNode = findNodeInTree(root, anchorId) ?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean); if (anchorNode?.type === 'CONDITION') { onChange(mergeAtRoot(root, newNode, data.type === 'operator')); return true; } if (!canAcceptPaletteAsParent(anchorId, root)) return false; if (root && findNodeInTree(root, anchorId)) { onChange(addChild(root, anchorId, newNode)); saveAttachHandles(anchorId); scheduleLayoutEmit(); return true; } const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId)); if (parentBranch) { const [sid, br] = parentBranch; onExtraRootsChange?.({ ...extraRoots, [sid]: addChild(br!, anchorId, newNode), }); saveAttachHandles(anchorId); scheduleLayoutEmit(); return true; } return false; }, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]); const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => { updateDropPreview(anchorId, flowPos); }, [updateDropPreview]); const handleDragLeaveTarget = useCallback((anchorId: string) => { setNodes(nds => nds.map(n => ( n.id === anchorId ? { ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined } } : n ))); }, [setNodes]); const handleDropTarget = useCallback(( anchorId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }, ) => { clearDropPreview(); if (isPaletteConnectTarget(anchorId, root, orphans)) { const attached = applyDropWithAttach(anchorId, data, flowPos); if (!attached) addOrphanAt(data, flowPos); } else { addOrphanAt(data, flowPos); } }, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]); const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => { const next = normalizeConditionForPersistence(condition); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => ( o.id === id ? { ...o, condition: next } : o ))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, n => ({ ...n, condition: next }))); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, n => ({ ...n, condition: next })), }); return; } } }, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]); const handleReplaceLogicNode = useCallback((id: string, replaced: LogicNode) => { if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => (o.id === id ? replaced : o))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, () => replaced)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, () => replaced), }); return; } } }, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]); const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => { const patchGate = (n: LogicNode) => ( n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType, stochPair: gateType === 'OR' ? n.stochPair : undefined, priceExtremePair: gateType === 'AND' ? n.priceExtremePair : undefined, inflection33Pair: n.inflection33Pair?.mode === 'buy' ? (gateType === 'AND' ? n.inflection33Pair : undefined) : n.inflection33Pair?.mode === 'sell' ? (gateType === 'OR' ? n.inflection33Pair : undefined) : undefined, stableStrategyPair: n.stableStrategyPair?.mode === 'buy' ? (gateType === 'AND' ? n.stableStrategyPair : undefined) : n.stableStrategyPair?.mode === 'sell' ? (gateType === 'OR' ? n.stableStrategyPair : undefined) : undefined, } : n ); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => ( o.id === id && (o.type === 'AND' || o.type === 'OR') ? patchGate(o) : o ))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, patchGate)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, patchGate), }); return; } } }, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]); const handleUpdateStochPairSecondary = useCallback((id: string, secondaryIndicator: string) => { const patchPair = (n: LogicNode) => setStochPairSecondary(n, secondaryIndicator, def); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, patchPair)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, patchPair), }); return; } } }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); const handleUpdatePriceExtremeFilterLevel = useCallback((id: string, filterLevel: PriceExtremeFilterLevel) => { const patchPair = (n: LogicNode) => setPriceExtremePairFilterLevel(n, filterLevel, def); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, patchPair)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, patchPair), }); return; } } }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); const handleUpdateInflection33FilterLevel = useCallback((id: string, filterLevel: Inflection33FilterLevel) => { const patchPair = (n: LogicNode) => setInflection33PairFilterLevel(n, filterLevel, def); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, patchPair)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, patchPair), }); return; } } }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); const handleUpdateStableStrategyFilterLevel = useCallback((id: string, filterLevel: StableStrategyFilterLevel) => { const patchPair = (n: LogicNode) => setStableStrategyPairFilterLevel(n, filterLevel, def); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); return; } if (root && findNodeInTree(root, id)) { onChange(updateNode(root, id, patchPair)); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, [sid]: updateNode(branch, id, patchPair), }); return; } } }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); const flowCallbacks = useMemo(() => ({ onDelete: handleDelete, onUpdateCondition: handleUpdateCondition, onReplaceLogicNode: handleReplaceLogicNode, onUpdateStochPairSecondary: handleUpdateStochPairSecondary, onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel, onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel, onUpdateStableStrategyFilterLevel: handleUpdateStableStrategyFilterLevel, onChangeLogicGateType: handleChangeLogicGateType, onDropTarget: handleDropTarget, onDragOverTarget: handleDragOverTarget, onDragLeaveTarget: handleDragLeaveTarget, onStartCandleTypesChange: handleStartCandleTypesChange, onDeleteStart: handleDeleteStart, }), [ handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget, handleStartCandleTypesChange, handleDeleteStart, ]); const rebuildFlow = useCallback(() => logicNodeToFlow( root, def, signalTab, positionsRef.current, flowCallbacks, null, orphans, startMeta, extraStartIds, extraRoots, ), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]); // 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지) useEffect(() => { if (isPaletteDragSessionActive()) return; const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow(); const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current); pruneEdgeHandles(edgeHandlesRef.current, mergedEdges); const structureChanged = structureKeyRef.current !== treeStructureKey; const orphansChanged = orphanKeyRef.current !== orphanKey; structureKeyRef.current = treeStructureKey; orphanKeyRef.current = orphanKey; const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => { const prev = nodesRef.current.find(p => p.id === n.id); const savedPos = layoutSeed.positions[n.id]; const dragOver = (prev?.data as StrategyFlowNodeData | undefined)?.dragOver; const activeSourceSide = (prev?.data as StrategyFlowNodeData | undefined)?.activeSourceSide; return { ...n, position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position, selected: prev?.selected ?? false, data: { ...n.data, ...(dragOver ? { dragOver, activeSourceSide } : {}), }, }; })); const hasSavedLayout = layoutNodes.some( n => n.id !== START_NODE_ID && layoutSeed.positions[n.id] != null, ); const needsNodeSync = layoutNodes.length !== nodesRef.current.length || !nodesRef.current.some(n => n.id === START_NODE_ID); if (structureChanged || orphansChanged || needsNodeSync) { syncNodesFromLayout(); setEdges(mergedEdges); if (structureChanged && !hasSavedLayout) { requestAnimationFrame(() => { fitView({ padding: 0.4, duration: 200 }); }); } } else { setNodes(prev => prev.map(n => { const fresh = layoutNodes.find(ln => ln.id === n.id); if (!fresh) return n; const savedPos = layoutSeed.positions[n.id]; const dragOver = (n.data as StrategyFlowNodeData).dragOver; const activeSourceSide = (n.data as StrategyFlowNodeData).activeSourceSide; return { ...n, position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position, data: { ...fresh.data, ...(dragOver ? { dragOver, activeSourceSide } : {}), }, }; })); setEdges(mergedEdges); } }, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView, layoutSeed]); const handleDisconnectEdge = useCallback((edgeId: string) => { const edge = edgesRef.current.find(e => e.id === edgeId); if (!edge?.source || !edge.target) return; edgeHandlesRef.current.delete(edgeId); const { root: nextRoot, orphans: nextOrphans, extraRoots: nextExtraRoots } = disconnectTreeEdge( edge.source, edge.target, root, orphans, extraRoots, ); if (nextRoot === root && nextOrphans.length === orphans.length && nextExtraRoots === extraRoots) return; onChange(nextRoot); onOrphansChange(nextOrphans); onExtraRootsChange?.(nextExtraRoots); onSelectNode(null); setEdges(prev => prev.map(e => ({ ...e, selected: false }))); }, [root, orphans, extraRoots, onChange, onOrphansChange, onExtraRootsChange, onSelectNode, setEdges]); useEffect(() => { edgeDisconnectRef.current = handleDisconnectEdge; edgeBindingUpdateRef.current = (edgeId, patch) => { const prev = edgeHandlesRef.current.get(edgeId) ?? {}; const next: EdgeHandleBinding = { ...prev, ...patch }; if (patch.sourceHandle != null) next.manualSource = true; if (patch.targetHandle != null) next.manualTarget = true; if (patch.centerX != null || patch.centerY != null) next.manualCenter = true; if (next.manualSource && next.manualTarget) next.manual = true; edgeHandlesRef.current.set(edgeId, next); setEdges(prevEdges => applyEdgeHandles(prevEdges, positionsRef.current, edgeHandlesRef.current)); scheduleLayoutEmit(); }; return () => { edgeDisconnectRef.current = null; edgeBindingUpdateRef.current = null; }; }, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]); const handleAutoLayout = useCallback(() => { const nextPositions = computeAutoFlowLayout(root, extraStartIds, extraRoots, orphans); positionsRef.current = new Map(Object.entries(nextPositions)); edgeHandlesRef.current.clear(); const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow(); const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current); setNodes(layoutNodes.map(n => ({ ...n, position: positionsRef.current.get(n.id) ?? n.position, selected: nodesRef.current.find(p => p.id === n.id)?.selected ?? false, }))); setEdges(mergedEdges); scheduleLayoutEmit(); requestAnimationFrame(() => { fitView({ padding: 0.35, duration: 320 }); }); }, [ root, extraStartIds, extraRoots, orphans, rebuildFlow, setNodes, setEdges, scheduleLayoutEmit, fitView, ]); const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => { const picked = selNodes.filter(n => !isStartNodeId(n.id)); if (picked.length === 1) onSelectNode(picked[0].id); else onSelectNode(null); if (selEdges.length === 1) { // 연결선만 선택된 경우 노드 선택 해제 if (picked.length === 0) onSelectNode(null); } }, [onSelectNode]); const isValidConnection = useCallback( (conn: Connection | Edge) => isValidStrategyConnection( { source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null }, root, orphans, extraRoots, ), [root, orphans, extraRoots], ); const applyResolvedConnection = useCallback(( parentId: string, childId: string, conn: Connection, ) => { const wired: Connection = { ...conn, source: parentId, target: childId }; saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${childId}`, wired); const childOrphan = orphans.find(o => o.id === childId); const parentOrphan = orphans.find(o => o.id === parentId); if (childOrphan) { onOrphansChange(orphans.filter(o => o.id !== childId)); connectOrphanIntoTree( wired, childOrphan, root, extraRoots, onChange, next => onExtraRootsChange?.(next), ); scheduleLayoutEmit(); return; } if (parentOrphan) { onOrphansChange(orphans.filter(o => o.id !== parentId)); connectOrphanAsParent(wired, parentOrphan, root, onChange); scheduleLayoutEmit(); return; } applyTreeConnection( parentId, childId, root, extraRoots, onChange, next => onExtraRootsChange?.(next), ); scheduleLayoutEmit(); }, [root, orphans, extraRoots, onChange, onExtraRootsChange, onOrphansChange, scheduleLayoutEmit]); const onNodesChange: OnNodesChange = useCallback((changes) => { const positionChanges = changes.filter( (ch): ch is Extract => ch.type === 'position' && !!ch.position, ); const startChange = positionChanges.find(ch => ch.id === START_NODE_ID); const otherTreePositionChanges = positionChanges.filter( ch => ch.id !== START_NODE_ID && treeLinkedIds.has(ch.id), ); let startGroupDelta: { dx: number; dy: number } | null = null; const startPos = startChange?.position; if (startChange && startPos && otherTreePositionChanges.length === 0) { const prev = positionsRef.current.get(START_NODE_ID); if (prev) { startGroupDelta = { dx: startPos.x - prev.x, dy: startPos.y - prev.y, }; } } onNodesChangeBase(changes); let positionsChanged = false; if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) { positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y }); for (const id of treeLinkedIds) { if (id === START_NODE_ID) continue; const p = positionsRef.current.get(id); if (p) { positionsRef.current.set(id, { x: p.x + startGroupDelta.dx, y: p.y + startGroupDelta.dy, }); } } setNodes(nds => nds.map(n => { if (!treeLinkedIds.has(n.id)) return n; const p = positionsRef.current.get(n.id); return p ? { ...n, position: { x: p.x, y: p.y } } : n; })); positionsChanged = true; } for (const ch of positionChanges) { if (!ch.position) continue; if (startGroupDelta && treeLinkedIds.has(ch.id)) continue; if (isOrphanNode(orphans, ch.id)) { positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y }); if (ch.dragging === true) { positionsChanged = true; const nodesSnapshot = nodesRef.current.map(n => ( n.id === ch.id ? { id: n.id, position: ch.position! } : { id: n.id, position: n.position } )); const resolved = resolveOrphanDragConnection( ch.id, ch.position, nodesSnapshot, root, orphans, extraRoots, ); orphanDragTargetRef.current = resolved; if (resolved) { updateOrphanConnectPreview(resolved, ch.id, ch.position); } else { clearDropPreview(); } } else if (ch.dragging === false) { const pending = orphanDragTargetRef.current; orphanDragTargetRef.current = null; clearDropPreview(); if (pending) { const conn = buildConnectionFromPositions( pending.parentId, pending.childId, positionsRef.current, ); applyResolvedConnection(pending.parentId, pending.childId, conn); } positionsChanged = true; } continue; } positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y }); positionsChanged = true; } if (positionsChanged) { setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current)); scheduleLayoutEmit(); } }, [ onNodesChangeBase, setNodes, setEdges, treeLinkedIds, orphans, root, clearDropPreview, updateOrphanConnectPreview, applyResolvedConnection, scheduleLayoutEmit, ]); const onConnect = useCallback((conn: Connection) => { const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots); if (!resolved) return; applyResolvedConnection(resolved.parentId, resolved.childId, conn); }, [root, orphans, extraRoots, applyResolvedConnection]); const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => { const resolved = resolveStrategyConnection(newConnection, root, orphans, extraRoots); if (!resolved) return; edgeHandlesRef.current.delete(oldEdge.id); const endpointsChanged = oldEdge.source !== resolved.parentId || oldEdge.target !== resolved.childId; if (endpointsChanged) { applyResolvedConnection(resolved.parentId, resolved.childId, newConnection); return; } saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection, true); setEdges(prev => applyEdgeHandles( prev.map(e => ( e.id === oldEdge.id ? { ...e, sourceHandle: newConnection.sourceHandle, targetHandle: newConnection.targetHandle, } : e )), positionsRef.current, edgeHandlesRef.current, )); }, [root, orphans, extraRoots, applyResolvedConnection, setEdges]); const resolveDropFromClient = useCallback((clientX: number, clientY: number) => ( resolvePaletteDropClientPoint( clientX, clientY, reactFlowRef.current, canvasWrapRef.current, nodesRef.current, findNodeAtFlowPosition, nodeId => isPaletteConnectTarget(nodeId, root, orphans), ) ), [root, orphans]); const isPaletteDrag = useCallback( (e: React.DragEvent) => isPaletteHtmlDragActive() || isPaletteHtmlDrag(e), [], ); const applyPaletteDropAt = useCallback(( data: { type: string; value: string; label: string; composite?: boolean }, clientX: number, clientY: number, ) => { const { flowPos, anchorId } = resolveDropFromClient(clientX, clientY); const overCanvas = isOverStrategyBuilder(clientX, clientY); if (!overCanvas && !anchorId) return; if (data.type === 'start') { addStartAt(flowPos); return; } if (anchorId) { handleDropTarget(anchorId, data, flowPos); return; } addOrphanAt(data, flowPos); requestAnimationFrame(() => { reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 }); }); }, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]); const onPaneDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); clearDropPreview(); const data = readPaletteHtmlDragData(e); if (!data) return; applyPaletteDropAt(data, e.clientX, e.clientY); }, [clearDropPreview, applyPaletteDropAt]); const applyDropRef = useRef(applyPaletteDropAt); applyDropRef.current = applyPaletteDropAt; const clearPreviewRef = useRef(clearDropPreview); clearPreviewRef.current = clearDropPreview; useLayoutEffect(() => { if (needsPointerPaletteDrag()) return undefined; setHtmlPaletteDropRouter((x, y, payload) => { clearPreviewRef.current(); applyDropRef.current(payload, x, y); }); return () => setHtmlPaletteDropRouter(null); }, []); useLayoutEffect(() => { if (!needsPointerPaletteDrag()) return undefined; setPaletteDragDropHandler((ev) => { if (ev.phase === 'end') { clearPreviewRef.current(); applyDropRef.current(ev.payload, ev.x, ev.y); } }); return () => setPaletteDragDropHandler(null); }, []); const updatePreviewRef = useRef(updateDropPreview); updatePreviewRef.current = updateDropPreview; const resolveDropFromClientRef = useRef(resolveDropFromClient); resolveDropFromClientRef.current = resolveDropFromClient; const lastPreviewAnchorRef = useRef(null); const lastPreviewKeyRef = useRef(null); useEffect(() => { if (needsPointerPaletteDrag()) return undefined; let rafId = 0; const tick = () => { if (!isPaletteHtmlDragActive()) { rafId = 0; return; } const xy = getLastHtmlPaletteDragClientXY(); if (xy && (xy.x !== 0 || xy.y !== 0)) { const { flowPos, anchorId } = resolveDropFromClientRef.current(xy.x, xy.y); if (anchorId) { updatePreviewRef.current(anchorId, flowPos); lastPreviewAnchorRef.current = anchorId; } else if (lastPreviewAnchorRef.current != null) { lastPreviewAnchorRef.current = null; clearPreviewRef.current(); } } rafId = requestAnimationFrame(tick); }; const onDragStart = () => { if (!rafId) rafId = requestAnimationFrame(tick); }; document.addEventListener('dragstart', onDragStart, true); return () => { document.removeEventListener('dragstart', onDragStart, true); if (rafId) cancelAnimationFrame(rafId); }; }, []); useEffect(() => { if (needsPointerPaletteDrag()) return undefined; const onDocDragOver = (e: DragEvent) => { if (!isPaletteHtmlDragActive()) return; e.preventDefault(); const { flowPos, anchorId } = resolveDropFromClientRef.current(e.clientX, e.clientY); if (anchorId) { updatePreviewRef.current(anchorId, flowPos); lastPreviewAnchorRef.current = anchorId; } }; document.addEventListener('dragover', onDocDragOver, { passive: false, capture: true }); return () => document.removeEventListener('dragover', onDocDragOver, { capture: true }); }, []); useEffect(() => { const onDocDragEnd = () => { if (!isPaletteHtmlDragActive()) return; lastPreviewAnchorRef.current = null; lastPreviewKeyRef.current = null; clearPreviewRef.current(); markPaletteHtmlDragEnd(); }; document.addEventListener('dragend', onDocDragEnd); return () => { document.removeEventListener('dragend', onDocDragEnd); }; }, []); useEffect(() => { return subscribePaletteDrag((ev: PaletteDragEvent) => { if (ev.phase === 'move') { const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y); if (anchorId) { lastPreviewAnchorRef.current = anchorId; lastPreviewKeyRef.current = `${anchorId}:${Math.round(flowPos.x / 8)}:${Math.round(flowPos.y / 8)}`; updatePreviewRef.current(anchorId, flowPos); return; } if (lastPreviewAnchorRef.current != null) { lastPreviewAnchorRef.current = null; lastPreviewKeyRef.current = null; clearPreviewRef.current(); } return; } if (ev.phase === 'end' || ev.phase === 'cancel') { lastPreviewAnchorRef.current = null; lastPreviewKeyRef.current = null; } if (ev.phase === 'cancel') { clearPreviewRef.current(); } }); }, []); const onPaneDragEnter = useCallback((e: React.DragEvent) => { if (!isPaletteDrag(e)) return; e.preventDefault(); }, [isPaletteDrag]); const onPaneDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); if (!isPaletteDrag(e)) return; e.dataTransfer.dropEffect = 'copy'; const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY); if (anchorId) { updateDropPreview(anchorId, flowPos); } }, [resolveDropFromClient, updateDropPreview, isPaletteDrag]); const onPaneDragLeave = useCallback((e: React.DragEvent) => { const rel = e.relatedTarget as Element | null; if (!rel) return; if (e.currentTarget.contains(rel)) return; clearDropPreview(); }, [clearDropPreview]); const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key !== 'Delete' && e.key !== 'Backspace') return; const selectedEdge = edgesRef.current.find(ed => ed.selected); if (selectedEdge) { e.preventDefault(); handleDisconnectEdge(selectedEdge.id); return; } const selectedCount = nodesRef.current.filter(n => n.selected && n.id !== START_NODE_ID).length; if (selectedCount > 1) { e.preventDefault(); deleteSelectedNodes(); } else if (selectedNodeId && selectedNodeId !== START_NODE_ID) { e.preventDefault(); handleDelete(selectedNodeId); } }, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]); return (
{ reactFlowRef.current = inst; }} nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onSelectionChange={onSelectionChange} onConnect={onConnect} isValidConnection={isValidConnection} onReconnect={onReconnect} onDrop={onPaneDrop} onDragEnter={onPaneDragEnter} onDragOver={onPaneDragOver} onDragLeave={onPaneDragLeave} connectionMode={ConnectionMode.Loose} edgesReconnectable reconnectRadius={24} minZoom={0.25} maxZoom={1.8} nodesDraggable nodesConnectable elementsSelectable selectionOnDrag={interactionMode === 'select'} selectionMode={SelectionMode.Partial} panOnDrag={interactionMode === 'pan' ? true : [1, 2]} selectNodesOnDrag={false} multiSelectionKeyCode={['Meta', 'Control', 'Shift']} deleteKeyCode={null} defaultEdgeOptions={{ type: 'strategy', className: 'se-flow-edge', style: { strokeWidth: 2 }, }} proOptions={{ hideAttribution: true }} > 전략 빌더
{interactionMode === 'pan' ? '빈 영역 드래그 → 그래프 이동 · 노드 드래그 → 위치 변경' : '빈 영역 드래그 → 다중 선택 · Del 삭제 · 연결선 × 끊기'} {orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
{!root && ( 우측에서 지표·연산자를 드래그하거나 클릭하여 조건을 구성하세요 )}
); } export default function StrategyEditorCanvas(props: Props) { return ; }