전략편집기 수정
This commit is contained in:
@@ -2277,6 +2277,8 @@ export default function StrategyEditorPage({
|
|||||||
onStartCandleTypesChange={handleStartCandleTypesChange}
|
onStartCandleTypesChange={handleStartCandleTypesChange}
|
||||||
onExtraStartIdsChange={handleExtraStartIdsChange}
|
onExtraStartIdsChange={handleExtraStartIdsChange}
|
||||||
onExtraRootsChange={handleExtraRootsChange}
|
onExtraRootsChange={handleExtraRootsChange}
|
||||||
|
startCombineOp={normalizeStartCombineOp(currentLayout.startCombineOp)}
|
||||||
|
onStartCombineOpChange={handleStartCombineOpChange}
|
||||||
/>
|
/>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
|||||||
activeSourceSide={d.activeSourceSide}
|
activeSourceSide={d.activeSourceSide}
|
||||||
canSourceConnect={d.canSourceConnect !== false}
|
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-layout">
|
||||||
<div className="se-flow-start-title-row">
|
<div className="se-flow-start-title-row">
|
||||||
<div className="se-flow-start-title-left">
|
<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,
|
DEFAULT_START_CANDLE,
|
||||||
normalizeStartCandleType,
|
normalizeStartCandleType,
|
||||||
type StartNodeMeta,
|
type StartNodeMeta,
|
||||||
|
type StartCombineOp,
|
||||||
} from '../../utils/strategyStartNodes';
|
} from '../../utils/strategyStartNodes';
|
||||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||||
|
import { StartCombineEdge } from './StartCombineEdge';
|
||||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +126,7 @@ const nodeTypes = {
|
|||||||
|
|
||||||
const edgeTypes = {
|
const edgeTypes = {
|
||||||
strategy: StrategyFlowEdge,
|
strategy: StrategyFlowEdge,
|
||||||
|
startCombine: StartCombineEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -146,6 +149,9 @@ interface Props {
|
|||||||
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
||||||
onExtraStartIdsChange?: (ids: string[]) => void;
|
onExtraStartIdsChange?: (ids: string[]) => void;
|
||||||
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
||||||
|
/** START 2개 이상일 때 분기 결합 (AND | OR) */
|
||||||
|
startCombineOp?: StartCombineOp;
|
||||||
|
onStartCombineOpChange?: (op: StartCombineOp) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrategyEditorCanvasHandle {
|
export interface StrategyEditorCanvasHandle {
|
||||||
@@ -314,6 +320,8 @@ function StrategyEditorCanvasInner({
|
|||||||
onStartCandleTypesChange,
|
onStartCandleTypesChange,
|
||||||
onExtraStartIdsChange,
|
onExtraStartIdsChange,
|
||||||
onExtraRootsChange,
|
onExtraRootsChange,
|
||||||
|
startCombineOp = 'AND',
|
||||||
|
onStartCombineOpChange,
|
||||||
}: Props, ref: React.ForwardedRef<StrategyEditorCanvasHandle>) {
|
}: Props, ref: React.ForwardedRef<StrategyEditorCanvasHandle>) {
|
||||||
const { fitView } = useReactFlow();
|
const { fitView } = useReactFlow();
|
||||||
const canvasWrapRef = useRef<HTMLDivElement>(null);
|
const canvasWrapRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -949,7 +957,9 @@ function StrategyEditorCanvasInner({
|
|||||||
startMeta,
|
startMeta,
|
||||||
extraStartIds,
|
extraStartIds,
|
||||||
extraRoots,
|
extraRoots,
|
||||||
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
|
startCombineOp,
|
||||||
|
onStartCombineOpChange,
|
||||||
|
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots, startCombineOp, onStartCombineOpChange]);
|
||||||
|
|
||||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1128,6 +1128,56 @@
|
|||||||
scale: 1.1;
|
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 {
|
.se-flow-edge-hit {
|
||||||
pointer-events: stroke;
|
pointer-events: stroke;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import {
|
|||||||
normalizeStartCandleType,
|
normalizeStartCandleType,
|
||||||
STRATEGY_CANDLE_TYPES,
|
STRATEGY_CANDLE_TYPES,
|
||||||
type StartNodeMeta,
|
type StartNodeMeta,
|
||||||
|
type StartCombineOp,
|
||||||
} from './strategyStartNodes';
|
} from './strategyStartNodes';
|
||||||
|
|
||||||
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
|
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
|
||||||
export type { StartNodeMeta };
|
export type { StartNodeMeta };
|
||||||
|
|
||||||
|
/** START 결합 엣지 식별 prefix */
|
||||||
|
export const START_COMBINE_EDGE_PREFIX = 'start-combine:';
|
||||||
|
|
||||||
const H_GAP = 280;
|
const H_GAP = 280;
|
||||||
const V_GAP = 130;
|
const V_GAP = 130;
|
||||||
|
|
||||||
@@ -436,6 +440,8 @@ export function applyEdgeHandles(
|
|||||||
saved: Map<string, EdgeHandleBinding>,
|
saved: Map<string, EdgeHandleBinding>,
|
||||||
): Edge[] {
|
): Edge[] {
|
||||||
return edges.map(edge => {
|
return edges.map(edge => {
|
||||||
|
// START 결합 엣지는 고정 핸들(s-bottom→t-top) 유지 — 핸들 재계산/바인딩 제외
|
||||||
|
if (edge.type === 'startCombine') return edge;
|
||||||
const binding = saved.get(edge.id) ?? {};
|
const binding = saved.get(edge.id) ?? {};
|
||||||
const picked = pickHandleSides(edge.source, edge.target, positions);
|
const picked = pickHandleSides(edge.source, edge.target, positions);
|
||||||
let sourceHandle = picked.sourceHandle;
|
let sourceHandle = picked.sourceHandle;
|
||||||
@@ -716,6 +722,8 @@ export function logicNodeToFlow(
|
|||||||
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
|
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
|
||||||
extraStartIds: string[] = [],
|
extraStartIds: string[] = [],
|
||||||
extraRoots: Record<string, LogicNode | null> = {},
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
|
startCombineOp: StartCombineOp = 'AND',
|
||||||
|
onStartCombineOpChange?: (op: StartCombineOp) => void,
|
||||||
): { nodes: Node[]; edges: Edge[] } {
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
@@ -766,6 +774,28 @@ export function logicNodeToFlow(
|
|||||||
appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140);
|
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);
|
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
|
|||||||
Reference in New Issue
Block a user