From 5edbb4d318380a5a7108cb891e083848a4c82d4b Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 1 Jul 2026 01:10:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 2 + .../components/strategyEditor/FlowNodes.tsx | 8 +++ .../strategyEditor/StartCombineEdge.tsx | 70 +++++++++++++++++++ .../strategyEditor/StrategyEditorCanvas.tsx | 12 +++- frontend/src/styles/strategyEditor.css | 50 +++++++++++++ frontend/src/utils/strategyFlowLayout.ts | 30 ++++++++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/strategyEditor/StartCombineEdge.tsx diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 8b05494..96d39f2 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -2277,6 +2277,8 @@ export default function StrategyEditorPage({ onStartCandleTypesChange={handleStartCandleTypesChange} onExtraStartIdsChange={handleExtraStartIdsChange} onExtraRootsChange={handleExtraRootsChange} + startCombineOp={normalizeStartCombineOp(currentLayout.startCombineOp)} + onStartCombineOpChange={handleStartCombineOpChange} /> diff --git a/frontend/src/components/strategyEditor/FlowNodes.tsx b/frontend/src/components/strategyEditor/FlowNodes.tsx index 2126c45..493ca5d 100644 --- a/frontend/src/components/strategyEditor/FlowNodes.tsx +++ b/frontend/src/components/strategyEditor/FlowNodes.tsx @@ -205,6 +205,14 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) { activeSourceSide={d.activeSourceSide} canSourceConnect={d.canSourceConnect !== false} /> + {/* START 결합 엣지(START끼리 자동 연결) 전용 target 핸들 — 비대화형, 엣지 부착용 */} +
diff --git a/frontend/src/components/strategyEditor/StartCombineEdge.tsx b/frontend/src/components/strategyEditor/StartCombineEdge.tsx new file mode 100644 index 0000000..d0cbe77 --- /dev/null +++ b/frontend/src/components/strategyEditor/StartCombineEdge.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { + BaseEdge, + EdgeLabelRenderer, + getSmoothStepPath, + type EdgeProps, +} from '@xyflow/react'; +import type { StartCombineOp } from '../../utils/strategyStartNodes'; + +export type StartCombineEdgeData = { + op?: StartCombineOp; + onToggle?: (op: StartCombineOp) => void; +}; + +/** + * START 노드끼리 잇는 결합 엣지 — 가운데 AND/OR 토글 배지. + * 클릭하면 startCombineOp 를 전환한다. (연결 자체는 START 2개 이상이면 자동 생성) + */ +export function StartCombineEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}: EdgeProps) { + const op: StartCombineOp = (data as StartCombineEdgeData | undefined)?.op === 'OR' ? 'OR' : 'AND'; + const onToggle = (data as StartCombineEdgeData | undefined)?.onToggle; + + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 10, + }); + + return ( + <> + + + + + + ); +} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 2018fa4..09aa40f 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -98,9 +98,11 @@ import { DEFAULT_START_CANDLE, normalizeStartCandleType, type StartNodeMeta, + type StartCombineOp, } from '../../utils/strategyStartNodes'; import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes'; import { StrategyFlowEdge } from './StrategyFlowEdge'; +import { StartCombineEdge } from './StartCombineEdge'; import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks'; import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton'; import { @@ -124,6 +126,7 @@ const nodeTypes = { const edgeTypes = { strategy: StrategyFlowEdge, + startCombine: StartCombineEdge, }; interface Props { @@ -146,6 +149,9 @@ interface Props { onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void; onExtraStartIdsChange?: (ids: string[]) => void; onExtraRootsChange?: (roots: Record) => void; + /** START 2개 이상일 때 분기 결합 (AND | OR) */ + startCombineOp?: StartCombineOp; + onStartCombineOpChange?: (op: StartCombineOp) => void; } export interface StrategyEditorCanvasHandle { @@ -314,6 +320,8 @@ function StrategyEditorCanvasInner({ onStartCandleTypesChange, onExtraStartIdsChange, onExtraRootsChange, + startCombineOp = 'AND', + onStartCombineOpChange, }: Props, ref: React.ForwardedRef) { const { fitView } = useReactFlow(); const canvasWrapRef = useRef(null); @@ -949,7 +957,9 @@ function StrategyEditorCanvasInner({ startMeta, extraStartIds, extraRoots, - ), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]); + startCombineOp, + onStartCombineOpChange, + ), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots, startCombineOp, onStartCombineOpChange]); // 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지) useEffect(() => { diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 75f8738..7bb4474 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -1128,6 +1128,56 @@ scale: 1.1; } +/* START 결합 엣지 — START끼리 자동 연결 (AND/OR) */ +.se-flow-start-combine-edge { + stroke: var(--se-edge); + opacity: 0.85; +} +.se-flow-start-combine-edge--and { + stroke: color-mix(in srgb, var(--se-accent) 80%, transparent); +} +.se-flow-start-combine-edge--or { + stroke: #00d4ff; +} +.se-start-combine-edge-badge { + z-index: 11; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 38px; + height: 22px; + padding: 0 9px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--se-accent) 55%, transparent); + background: rgba(15, 23, 42, 0.96); + color: var(--se-accent); + font-size: 0.66rem; + font-weight: 800; + letter-spacing: 0.04em; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); + transition: background 0.15s, transform 0.12s, border-color 0.15s; +} +.se-start-combine-edge-badge:hover { + transform: scale(1.08); +} +.se-start-combine-edge-badge--or { + border-color: rgba(0, 212, 255, 0.6); + color: #00d4ff; +} + +/* START 결합 전용 target 핸들 — 비대화형, 시각적으로 최소화 */ +.se-flow-handle--start-combine-tgt { + width: 1px; + height: 1px; + min-width: 1px; + min-height: 1px; + background: transparent; + border: none; + opacity: 0; + pointer-events: none; +} + .se-flow-edge-hit { pointer-events: stroke; } diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts index eec7921..80d6542 100644 --- a/frontend/src/utils/strategyFlowLayout.ts +++ b/frontend/src/utils/strategyFlowLayout.ts @@ -10,11 +10,15 @@ import { normalizeStartCandleType, STRATEGY_CANDLE_TYPES, type StartNodeMeta, + type StartCombineOp, } from './strategyStartNodes'; export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES }; export type { StartNodeMeta }; +/** START 결합 엣지 식별 prefix */ +export const START_COMBINE_EDGE_PREFIX = 'start-combine:'; + const H_GAP = 280; const V_GAP = 130; @@ -436,6 +440,8 @@ export function applyEdgeHandles( saved: Map, ): Edge[] { return edges.map(edge => { + // START 결합 엣지는 고정 핸들(s-bottom→t-top) 유지 — 핸들 재계산/바인딩 제외 + if (edge.type === 'startCombine') return edge; const binding = saved.get(edge.id) ?? {}; const picked = pickHandleSides(edge.source, edge.target, positions); let sourceHandle = picked.sourceHandle; @@ -716,6 +722,8 @@ export function logicNodeToFlow( startMeta: Record = defaultStartMeta(), extraStartIds: string[] = [], extraRoots: Record = {}, + startCombineOp: StartCombineOp = 'AND', + onStartCombineOpChange?: (op: StartCombineOp) => void, ): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = []; const edges: Edge[] = []; @@ -766,6 +774,28 @@ export function logicNodeToFlow( appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140); }); + // START 2개 이상이면 START끼리 자동 결합 엣지(체인)로 연결 — AND/OR 토글 배지 + if (extraStartIds.length >= 1) { + const chain = [START_NODE_ID, ...extraStartIds]; + for (let i = 1; i < chain.length; i += 1) { + const sourceId = chain[i - 1]; + const targetId = chain[i]; + edges.push({ + id: `${START_COMBINE_EDGE_PREFIX}${sourceId}->${targetId}`, + source: sourceId, + sourceHandle: 's-bottom', + target: targetId, + targetHandle: 't-top', + type: 'startCombine', + selectable: false, + deletable: false, + reconnectable: false, + focusable: false, + data: { op: startCombineOp, onToggle: onStartCombineOpChange }, + }); + } + } + appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks); return { nodes, edges };