전략편집기 수정
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user