전략편집기 수정

This commit is contained in:
Macbook
2026-07-01 01:10:22 +09:00
parent e72af1a9ed
commit 5edbb4d318
6 changed files with 171 additions and 1 deletions
@@ -2277,6 +2277,8 @@ export default function StrategyEditorPage({
onStartCandleTypesChange={handleStartCandleTypesChange}
onExtraStartIdsChange={handleExtraStartIdsChange}
onExtraRootsChange={handleExtraRootsChange}
startCombineOp={normalizeStartCombineOp(currentLayout.startCombineOp)}
onStartCombineOpChange={handleStartCombineOpChange}
/>
</ReactFlowProvider>
@@ -205,6 +205,14 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
activeSourceSide={d.activeSourceSide}
canSourceConnect={d.canSourceConnect !== false}
/>
{/* START 결합 엣지(START끼리 자동 연결) 전용 target 핸들 — 비대화형, 엣지 부착용 */}
<Handle
type="target"
position={Position.Top}
id="t-top"
isConnectable={false}
className="se-flow-handle se-flow-handle--start-combine-tgt"
/>
<div className="se-flow-start-layout">
<div className="se-flow-start-title-row">
<div className="se-flow-start-title-left">
@@ -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 (
<>
<BaseEdge
id={id}
path={edgePath}
className={`se-flow-start-combine-edge se-flow-start-combine-edge--${op.toLowerCase()}`}
style={{ strokeWidth: 2, strokeDasharray: '6 4', pointerEvents: 'none' }}
/>
<EdgeLabelRenderer>
<button
type="button"
className={`se-start-combine-edge-badge se-start-combine-edge-badge--${op.toLowerCase()}`}
title={op === 'AND' ? 'START 간 AND (모두 충족) — 클릭하면 OR' : 'START 간 OR (하나만 충족) — 클릭하면 AND'}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
onClick={(ev) => {
ev.stopPropagation();
onToggle?.(op === 'AND' ? 'OR' : 'AND');
}}
>
{op}
</button>
</EdgeLabelRenderer>
</>
);
}
@@ -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<string, LogicNode | null>) => 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<StrategyEditorCanvasHandle>) {
const { fitView } = useReactFlow();
const canvasWrapRef = useRef<HTMLDivElement>(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(() => {
+50
View File
@@ -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;
}
+30
View File
@@ -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<string, EdgeHandleBinding>,
): 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<string, StartNodeMeta> = defaultStartMeta(),
extraStartIds: string[] = [],
extraRoots: Record<string, LogicNode | null> = {},
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 };