968 lines
32 KiB
TypeScript
968 lines
32 KiB
TypeScript
import type { Connection, Edge, Node } from '@xyflow/react';
|
|
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
|
import { nodeToText, type DefType } from './strategyEditorShared';
|
|
import { formatIndicatorPeriodLabel } from './strategyIndicatorPeriods';
|
|
import {
|
|
START_NODE_ID,
|
|
defaultStartMeta,
|
|
getStartCandleTypes,
|
|
isStartNodeId,
|
|
normalizeStartCandleType,
|
|
STRATEGY_CANDLE_TYPES,
|
|
type StartNodeMeta,
|
|
} from './strategyStartNodes';
|
|
|
|
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
|
|
export type { StartNodeMeta };
|
|
|
|
const H_GAP = 280;
|
|
const V_GAP = 130;
|
|
|
|
export type HandleSide = 'top' | 'right' | 'bottom' | 'left';
|
|
|
|
const OPPOSE_SIDE: Record<HandleSide, HandleSide> = {
|
|
top: 'bottom',
|
|
bottom: 'top',
|
|
left: 'right',
|
|
right: 'left',
|
|
};
|
|
|
|
export type StrategyFlowNodeData = {
|
|
logicNode?: LogicNode;
|
|
label?: string;
|
|
def?: DefType;
|
|
signalTab?: 'buy' | 'sell';
|
|
selected?: boolean;
|
|
/** START 노드 — 조건 판별 시간봉 (표시용 대표) */
|
|
candleType?: string;
|
|
candleTypes?: string[];
|
|
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
|
onDeleteStart?: (startId: string) => void;
|
|
onDelete?: (id: string) => void;
|
|
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
|
/** Stoch 과열×보조 복합 — 보조지표 변경 */
|
|
onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void;
|
|
/** 9·20 신고가/신저가 복합 — 필터 강도 변경 */
|
|
onUpdatePriceExtremeFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void;
|
|
/** 33변곡 EMA+채널 복합 — 필터 강도 변경 */
|
|
onUpdateInflection33FilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void;
|
|
/** 추천 안정형 전략 — 필터 강도 변경 */
|
|
onUpdateStableStrategyFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void;
|
|
/** AND ↔ OR 전환 */
|
|
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
|
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
|
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
|
onDragLeaveTarget?: (targetId: string) => void;
|
|
dragOver?: boolean;
|
|
activeSourceSide?: HandleSide | null;
|
|
/** 논리 트리상 자식을 가질 수 있으면 true (START·AND·OR·빈 NOT) */
|
|
canSourceConnect?: boolean;
|
|
/** 논리 트리상 부모를 가질 수 있으면 true (조건·연산자, START 제외) */
|
|
canTargetConnect?: boolean;
|
|
/** 전략 트리 미연결 — 편집기에만 표시, 수식·저장 제외 */
|
|
isOrphan?: boolean;
|
|
};
|
|
|
|
export function isOrphanNode(orphans: LogicNode[], id: string): boolean {
|
|
return orphans.some(o => o.id === id);
|
|
}
|
|
|
|
export function findLogicNode(
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[],
|
|
id: string,
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): LogicNode | null {
|
|
if (isStartNodeId(id)) return null;
|
|
const inTree = findNodeInTree(root, id);
|
|
if (inTree) return inTree;
|
|
for (const extraRoot of Object.values(extraRoots)) {
|
|
const hit = findNodeInTree(extraRoot, id);
|
|
if (hit) return hit;
|
|
}
|
|
return orphans.find(o => o.id === id) ?? null;
|
|
}
|
|
|
|
/** 팔레트 드롭 시 트리에 즉시 연결되는 대상(START·트리 내 노드) */
|
|
export function isPaletteConnectTarget(
|
|
anchorId: string,
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[],
|
|
): boolean {
|
|
if (isOrphanNode(orphans, anchorId)) return false;
|
|
if (isStartNodeId(anchorId)) return true;
|
|
if (!root) return false;
|
|
return !!findNodeInTree(root, anchorId);
|
|
}
|
|
|
|
/** 노드별 연결 가능 역할 (DSL 트리 규칙) */
|
|
export function getNodeConnectionCaps(
|
|
nodeId: string,
|
|
logicNode: LogicNode | undefined,
|
|
): { canSource: boolean; canTarget: boolean } {
|
|
if (nodeId === START_NODE_ID || isStartNodeId(nodeId)) return { canSource: true, canTarget: false };
|
|
if (!logicNode) return { canSource: false, canTarget: false };
|
|
switch (logicNode.type) {
|
|
case 'CONDITION':
|
|
return { canSource: false, canTarget: true };
|
|
case 'NOT':
|
|
return { canSource: (logicNode.children?.length ?? 0) < 1, canTarget: true };
|
|
case 'AND':
|
|
case 'OR':
|
|
return { canSource: true, canTarget: true };
|
|
default:
|
|
return { canSource: false, canTarget: false };
|
|
}
|
|
}
|
|
|
|
/** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */
|
|
export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean {
|
|
if (isStartNodeId(anchorId)) return true;
|
|
if (!root) return false;
|
|
const node = findNodeInTree(root, anchorId);
|
|
if (!node || node.type === 'CONDITION') return false;
|
|
return getNodeConnectionCaps(anchorId, node).canSource;
|
|
}
|
|
|
|
/**
|
|
* DSL 부모→자식 방향으로 연결 가능한지 (source=부모, target=자식).
|
|
* React Flow Loose 모드에서는 conn.source/target이 드래그 방향에 따라 뒤집힐 수 있음.
|
|
*/
|
|
export function isValidStrategyConnectionDirected(
|
|
parentId: string,
|
|
childId: string,
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[] = [],
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): boolean {
|
|
if (!parentId || !childId || parentId === childId) return false;
|
|
if (isStartNodeId(childId)) return false;
|
|
|
|
const childOrphan = orphans.find(o => o.id === childId);
|
|
const parentOrphan = orphans.find(o => o.id === parentId);
|
|
|
|
if (childOrphan) {
|
|
if (isStartNodeId(parentId)) return true;
|
|
const parentNode = findNodeInTree(root, parentId)
|
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean)
|
|
?? null;
|
|
if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false;
|
|
return true;
|
|
}
|
|
|
|
if (parentOrphan) {
|
|
if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false;
|
|
const childNode = findNodeInTree(root, childId)
|
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean)
|
|
?? null;
|
|
if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false;
|
|
if (root && isDescendant(root, childId, parentId)) return false;
|
|
for (const er of Object.values(extraRoots)) {
|
|
if (er && isDescendant(er, childId, parentId)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (!root && Object.values(extraRoots).every(r => !r)) return false;
|
|
|
|
const parentNode = isStartNodeId(parentId)
|
|
? null
|
|
: findNodeInTree(root, parentId)
|
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean)
|
|
?? null;
|
|
const childNode = findNodeInTree(root, childId)
|
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean)
|
|
?? null;
|
|
if (!isStartNodeId(parentId) && !parentNode) return false;
|
|
if (!childNode) return false;
|
|
|
|
const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined);
|
|
const childCaps = getNodeConnectionCaps(childId, childNode);
|
|
if (!parentCaps.canSource || !childCaps.canTarget) return false;
|
|
|
|
if (!isStartNodeId(parentId)) {
|
|
if (root && isDescendant(root, childId, parentId)) return false;
|
|
for (const er of Object.values(extraRoots)) {
|
|
if (er && isDescendant(er, childId, parentId)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** 수동 연결·재연결 — 드래그 방향과 무관하게 부모→자식이 성립하면 유효 */
|
|
export function isValidStrategyConnection(
|
|
conn: Connection,
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[] = [],
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): boolean {
|
|
const { source, target } = conn;
|
|
if (!source || !target) return false;
|
|
return (
|
|
isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots)
|
|
|| isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots)
|
|
);
|
|
}
|
|
|
|
export function resolveStrategyConnection(
|
|
conn: Connection,
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[] = [],
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): { parentId: string; childId: string } | null {
|
|
const { source, target } = conn;
|
|
if (!source || !target) return null;
|
|
if (isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots)) {
|
|
return { parentId: source, childId: target };
|
|
}
|
|
if (isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots)) {
|
|
return { parentId: target, childId: source };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getNodeDimensions(nodeId: string): { w: number; h: number } {
|
|
return isStartNodeId(nodeId)
|
|
? { w: FLOW_START_W, h: FLOW_START_H }
|
|
: { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
|
}
|
|
|
|
/** 커서/드롭 위치가 노드 중심 기준 어느 방향인지 */
|
|
export function pickSideFromPoint(
|
|
flowPos: { x: number; y: number },
|
|
nodePos: { x: number; y: number },
|
|
dim: { w: number; h: number },
|
|
): HandleSide {
|
|
const cx = nodePos.x + dim.w / 2;
|
|
const cy = nodePos.y + dim.h / 2;
|
|
const dx = flowPos.x - cx;
|
|
const dy = flowPos.y - cy;
|
|
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left';
|
|
return dy >= 0 ? 'bottom' : 'top';
|
|
}
|
|
|
|
/** 드롭이 앵커의 좌/우/상/하 쪽이면 해당 면 연결점 활성화 */
|
|
export function connectionSidesFromDrop(
|
|
anchorPos: { x: number; y: number },
|
|
anchorDim: { w: number; h: number },
|
|
dropPos: { x: number; y: number },
|
|
): { sourceSide: HandleSide; targetSide: HandleSide; sourceHandle: string; targetHandle: string } {
|
|
const sourceSide = pickSideFromPoint(dropPos, anchorPos, anchorDim);
|
|
const targetSide = OPPOSE_SIDE[sourceSide];
|
|
return {
|
|
sourceSide,
|
|
targetSide,
|
|
sourceHandle: `s-${sourceSide}`,
|
|
targetHandle: `t-${targetSide}`,
|
|
};
|
|
}
|
|
|
|
export function spawnPositionNearAnchor(
|
|
anchorPos: { x: number; y: number },
|
|
anchorDim: { w: number; h: number },
|
|
side: HandleSide,
|
|
): { x: number; y: number } {
|
|
const gap = 48;
|
|
const child = { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
|
switch (side) {
|
|
case 'right':
|
|
return { x: anchorPos.x + anchorDim.w + gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 };
|
|
case 'left':
|
|
return { x: anchorPos.x - child.w - gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 };
|
|
case 'bottom':
|
|
return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y + anchorDim.h + gap };
|
|
case 'top':
|
|
return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y - child.h - gap };
|
|
}
|
|
}
|
|
|
|
export function findNodeAtFlowPosition(
|
|
flowPos: { x: number; y: number },
|
|
nodes: { id: string; position: { x: number; y: number } }[],
|
|
padding = 16,
|
|
): { id: string; position: { x: number; y: number } } | null {
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const n = nodes[i];
|
|
const dim = getNodeDimensions(n.id);
|
|
if (
|
|
flowPos.x >= n.position.x - padding
|
|
&& flowPos.x <= n.position.x + dim.w + padding
|
|
&& flowPos.y >= n.position.y - padding
|
|
&& flowPos.y <= n.position.y + dim.h + padding
|
|
) {
|
|
return n;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 미연결 고아 노드를 드래그해 다른 노드 위에 올렸을 때 연결 방향 해석 */
|
|
export function resolveOrphanDragConnection(
|
|
orphanId: string,
|
|
orphanPos: { x: number; y: number },
|
|
nodes: { id: string; position: { x: number; y: number } }[],
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[],
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): { parentId: string; childId: string } | null {
|
|
const dim = getNodeDimensions(orphanId);
|
|
const center = {
|
|
x: orphanPos.x + dim.w / 2,
|
|
y: orphanPos.y + dim.h / 2,
|
|
};
|
|
const hit = findNodeAtFlowPosition(
|
|
center,
|
|
nodes.filter(n => n.id !== orphanId),
|
|
);
|
|
if (!hit) return null;
|
|
if (isOrphanNode(orphans, hit.id)) return null;
|
|
|
|
return resolveStrategyConnection(
|
|
{ source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null },
|
|
root,
|
|
orphans,
|
|
extraRoots,
|
|
) ?? resolveStrategyConnection(
|
|
{ source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null },
|
|
root,
|
|
orphans,
|
|
extraRoots,
|
|
);
|
|
}
|
|
|
|
/** 드래그 연결 시 부모→자식 핸들 방향 */
|
|
export function buildConnectionFromPositions(
|
|
parentId: string,
|
|
childId: string,
|
|
positions: Map<string, { x: number; y: number }>,
|
|
): Connection {
|
|
const parentPos = positions.get(parentId) ?? { x: 0, y: 0 };
|
|
const childPos = positions.get(childId) ?? { x: 0, y: 0 };
|
|
const parentDim = getNodeDimensions(parentId);
|
|
const childDim = getNodeDimensions(childId);
|
|
const childCenter = {
|
|
x: childPos.x + childDim.w / 2,
|
|
y: childPos.y + childDim.h / 2,
|
|
};
|
|
const { sourceHandle, targetHandle } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
|
|
return {
|
|
source: parentId,
|
|
target: childId,
|
|
sourceHandle,
|
|
targetHandle,
|
|
};
|
|
}
|
|
|
|
export type EdgeHandleBinding = {
|
|
sourceHandle?: string | null;
|
|
targetHandle?: string | null;
|
|
/** smooth-step 경로 굽힘 중심 (중간 영역 드래그) */
|
|
centerX?: number | null;
|
|
centerY?: number | null;
|
|
manual?: boolean;
|
|
manualSource?: boolean;
|
|
manualTarget?: boolean;
|
|
manualCenter?: boolean;
|
|
};
|
|
|
|
export const FLOW_NODE_W = 200;
|
|
export const FLOW_NODE_H = 72;
|
|
export const FLOW_START_W = 200;
|
|
export const FLOW_START_H = 78;
|
|
|
|
function nodeCenter(
|
|
id: string,
|
|
positions: Map<string, { x: number; y: number }>,
|
|
): { x: number; y: number } {
|
|
const p = positions.get(id) ?? { x: 0, y: 0 };
|
|
const w = isStartNodeId(id) ? FLOW_START_W : FLOW_NODE_W;
|
|
const h = isStartNodeId(id) ? FLOW_START_H : FLOW_NODE_H;
|
|
return { x: p.x + w / 2, y: p.y + h / 2 };
|
|
}
|
|
|
|
/** 두 노드 상대 위치에 따라 최적 연결 방향(핸들) 선택 */
|
|
export function pickHandleSides(
|
|
sourceId: string,
|
|
targetId: string,
|
|
positions: Map<string, { x: number; y: number }>,
|
|
): { sourceHandle: string; targetHandle: string } {
|
|
const s = nodeCenter(sourceId, positions);
|
|
const t = nodeCenter(targetId, positions);
|
|
const dx = t.x - s.x;
|
|
const dy = t.y - s.y;
|
|
|
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
return dx >= 0
|
|
? { sourceHandle: 's-right', targetHandle: 't-left' }
|
|
: { sourceHandle: 's-left', targetHandle: 't-right' };
|
|
}
|
|
return dy >= 0
|
|
? { sourceHandle: 's-bottom', targetHandle: 't-top' }
|
|
: { sourceHandle: 's-top', targetHandle: 't-bottom' };
|
|
}
|
|
|
|
/** 경로 굽힘 중심 기준 — 양쪽 노드에 가장 자연스러운 핸들 */
|
|
export function pickHandlesFromCenter(
|
|
sourceId: string,
|
|
targetId: string,
|
|
center: { x: number; y: number },
|
|
positions: Map<string, { x: number; y: number }>,
|
|
): { sourceHandle: string; targetHandle: string } {
|
|
const sourcePos = positions.get(sourceId) ?? { x: 0, y: 0 };
|
|
const targetPos = positions.get(targetId) ?? { x: 0, y: 0 };
|
|
const sourceDim = getNodeDimensions(sourceId);
|
|
const targetDim = getNodeDimensions(targetId);
|
|
const sourceSide = pickSideFromPoint(center, sourcePos, sourceDim);
|
|
const targetSide = pickSideFromPoint(center, targetPos, targetDim);
|
|
return {
|
|
sourceHandle: `s-${sourceSide}`,
|
|
targetHandle: `t-${targetSide}`,
|
|
};
|
|
}
|
|
|
|
export function applyEdgeHandles(
|
|
edges: Edge[],
|
|
positions: Map<string, { x: number; y: number }>,
|
|
saved: Map<string, EdgeHandleBinding>,
|
|
): Edge[] {
|
|
return edges.map(edge => {
|
|
const binding = saved.get(edge.id) ?? {};
|
|
const picked = pickHandleSides(edge.source, edge.target, positions);
|
|
let sourceHandle = picked.sourceHandle;
|
|
let targetHandle = picked.targetHandle;
|
|
|
|
if (
|
|
binding.manualCenter
|
|
&& binding.centerX != null
|
|
&& binding.centerY != null
|
|
) {
|
|
const natural = pickHandlesFromCenter(
|
|
edge.source,
|
|
edge.target,
|
|
{ x: binding.centerX, y: binding.centerY },
|
|
positions,
|
|
);
|
|
if (!binding.manualSource) sourceHandle = natural.sourceHandle;
|
|
else sourceHandle = binding.sourceHandle ?? picked.sourceHandle;
|
|
if (!binding.manualTarget) targetHandle = natural.targetHandle;
|
|
else targetHandle = binding.targetHandle ?? picked.targetHandle;
|
|
} else {
|
|
if (binding.manualSource) sourceHandle = binding.sourceHandle ?? picked.sourceHandle;
|
|
if (binding.manualTarget) targetHandle = binding.targetHandle ?? picked.targetHandle;
|
|
}
|
|
|
|
const nextBinding: EdgeHandleBinding = {
|
|
...binding,
|
|
sourceHandle,
|
|
targetHandle,
|
|
};
|
|
saved.set(edge.id, nextBinding);
|
|
|
|
return {
|
|
...edge,
|
|
sourceHandle,
|
|
targetHandle,
|
|
data: { ...(edge.data as object), binding: nextBinding },
|
|
};
|
|
});
|
|
}
|
|
|
|
function layoutTree(
|
|
node: LogicNode,
|
|
parentId: string,
|
|
depth: number,
|
|
siblingIndex: number,
|
|
siblingCount: number,
|
|
nodes: Node[],
|
|
edges: Edge[],
|
|
positions: Map<string, { x: number; y: number }>,
|
|
def: DefType,
|
|
signalTab: 'buy' | 'sell',
|
|
callbacks: Pick<
|
|
StrategyFlowNodeData,
|
|
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType'
|
|
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
|
>,
|
|
dragOverId: string | null,
|
|
): void {
|
|
const yBase = 220;
|
|
const y = yBase + (siblingIndex - (siblingCount - 1) / 2) * V_GAP;
|
|
const pos = positions.get(node.id) ?? { x: 80 + depth * H_GAP, y };
|
|
const flowType = node.type === 'CONDITION' ? 'condition' : 'logic';
|
|
const caps = getNodeConnectionCaps(node.id, node);
|
|
|
|
nodes.push({
|
|
id: node.id,
|
|
type: flowType,
|
|
position: pos,
|
|
data: {
|
|
logicNode: node,
|
|
label: nodeToText(node, def),
|
|
def,
|
|
signalTab,
|
|
onDelete: callbacks.onDelete,
|
|
onUpdateCondition: callbacks.onUpdateCondition,
|
|
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
|
onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel,
|
|
onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel,
|
|
onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel,
|
|
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
|
onDropTarget: callbacks.onDropTarget,
|
|
onDragOverTarget: callbacks.onDragOverTarget,
|
|
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
|
dragOver: dragOverId === node.id,
|
|
canSourceConnect: caps.canSource,
|
|
canTargetConnect: caps.canTarget,
|
|
} satisfies StrategyFlowNodeData,
|
|
draggable: true,
|
|
});
|
|
|
|
edges.push({
|
|
id: `${parentId}-${node.id}`,
|
|
source: parentId,
|
|
target: node.id,
|
|
type: 'strategy',
|
|
animated: node.type === 'CONDITION',
|
|
className: 'se-flow-edge',
|
|
});
|
|
|
|
const children = node.children ?? [];
|
|
children.forEach((child, i) => {
|
|
layoutTree(child, node.id, depth + 1, i, children.length, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
|
|
});
|
|
}
|
|
|
|
const AUTO_LAYOUT_ORIGIN_X = 48;
|
|
const AUTO_LAYOUT_ORIGIN_Y = 48;
|
|
const AUTO_LAYOUT_SECTION_GAP = 72;
|
|
const AUTO_LAYOUT_START_TREE_GAP = 72;
|
|
const AUTO_LAYOUT_ORPHAN_GAP = 100;
|
|
|
|
function measureSubtreeHeight(node: LogicNode): number {
|
|
const children = node.children ?? [];
|
|
if (children.length === 0) return FLOW_NODE_H;
|
|
const childHeights = children.map(measureSubtreeHeight);
|
|
return childHeights.reduce((sum, h) => sum + h, 0) + (children.length - 1) * V_GAP;
|
|
}
|
|
|
|
function placeSubtree(
|
|
node: LogicNode,
|
|
x: number,
|
|
centerY: number,
|
|
positions: Map<string, { x: number; y: number }>,
|
|
): void {
|
|
const children = node.children ?? [];
|
|
if (children.length === 0) {
|
|
positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 });
|
|
return;
|
|
}
|
|
const childHeights = children.map(measureSubtreeHeight);
|
|
const totalHeight = childHeights.reduce((a, b) => a + b, 0) + (children.length - 1) * V_GAP;
|
|
let y = centerY - totalHeight / 2;
|
|
children.forEach((child, i) => {
|
|
const ch = childHeights[i];
|
|
placeSubtree(child, x + H_GAP, y + ch / 2, positions);
|
|
y += ch + V_GAP;
|
|
});
|
|
positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 });
|
|
}
|
|
|
|
function maxLayoutX(positions: Map<string, { x: number; y: number }>): number {
|
|
let max = 0;
|
|
for (const [id, pos] of positions) {
|
|
const dim = getNodeDimensions(id);
|
|
max = Math.max(max, pos.x + dim.w);
|
|
}
|
|
return max;
|
|
}
|
|
|
|
/** 그래프 캔버스 자동 정렬 — START별 트리를 좌→우, 형제는 위→아래 배치 */
|
|
export function computeAutoFlowLayout(
|
|
root: LogicNode | null,
|
|
extraStartIds: string[],
|
|
extraRoots: Record<string, LogicNode | null>,
|
|
orphans: LogicNode[],
|
|
): Record<string, { x: number; y: number }> {
|
|
const positions = new Map<string, { x: number; y: number }>();
|
|
let yCursor = AUTO_LAYOUT_ORIGIN_Y;
|
|
|
|
const placeStartSection = (startId: string, branchRoot: LogicNode | null) => {
|
|
const startDim = getNodeDimensions(startId);
|
|
const branchHeight = branchRoot ? measureSubtreeHeight(branchRoot) : 0;
|
|
const sectionHeight = Math.max(startDim.h, branchHeight);
|
|
const centerY = yCursor + sectionHeight / 2;
|
|
|
|
positions.set(startId, {
|
|
x: AUTO_LAYOUT_ORIGIN_X,
|
|
y: centerY - startDim.h / 2,
|
|
});
|
|
|
|
if (branchRoot) {
|
|
placeSubtree(
|
|
branchRoot,
|
|
AUTO_LAYOUT_ORIGIN_X + startDim.w + AUTO_LAYOUT_START_TREE_GAP,
|
|
centerY,
|
|
positions,
|
|
);
|
|
}
|
|
|
|
yCursor += sectionHeight + AUTO_LAYOUT_SECTION_GAP;
|
|
};
|
|
|
|
placeStartSection(START_NODE_ID, root);
|
|
for (const startId of extraStartIds) {
|
|
placeStartSection(startId, extraRoots[startId] ?? null);
|
|
}
|
|
|
|
if (orphans.length > 0) {
|
|
let orphanY = AUTO_LAYOUT_ORIGIN_Y;
|
|
const orphanX = (positions.size > 0 ? maxLayoutX(positions) : AUTO_LAYOUT_ORIGIN_X) + AUTO_LAYOUT_ORPHAN_GAP;
|
|
for (const orphan of orphans) {
|
|
const h = measureSubtreeHeight(orphan);
|
|
positions.set(orphan.id, { x: orphanX, y: orphanY });
|
|
orphanY += h + V_GAP;
|
|
}
|
|
}
|
|
|
|
return Object.fromEntries(positions);
|
|
}
|
|
|
|
function appendOrphanFlowNodes(
|
|
orphans: LogicNode[],
|
|
nodes: Node[],
|
|
positions: Map<string, { x: number; y: number }>,
|
|
def: DefType,
|
|
signalTab: 'buy' | 'sell',
|
|
callbacks: Pick<
|
|
StrategyFlowNodeData,
|
|
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType'
|
|
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
|
>,
|
|
): void {
|
|
let orphanY = 80;
|
|
for (const node of orphans) {
|
|
const pos = positions.get(node.id) ?? { x: 40, y: orphanY };
|
|
orphanY += V_GAP;
|
|
const flowType = node.type === 'CONDITION' ? 'condition' : 'logic';
|
|
const caps = getNodeConnectionCaps(node.id, node);
|
|
nodes.push({
|
|
id: node.id,
|
|
type: flowType,
|
|
position: pos,
|
|
className: 'se-flow-node-orphan',
|
|
data: {
|
|
logicNode: node,
|
|
label: nodeToText(node, def),
|
|
def,
|
|
signalTab,
|
|
onDelete: callbacks.onDelete,
|
|
onUpdateCondition: callbacks.onUpdateCondition,
|
|
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
|
onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel,
|
|
onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel,
|
|
onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel,
|
|
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
|
onDropTarget: callbacks.onDropTarget,
|
|
onDragOverTarget: callbacks.onDragOverTarget,
|
|
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
|
canSourceConnect: caps.canSource,
|
|
canTargetConnect: caps.canTarget,
|
|
isOrphan: true,
|
|
} satisfies StrategyFlowNodeData,
|
|
draggable: true,
|
|
selectable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function logicNodeToFlow(
|
|
root: LogicNode | null,
|
|
def: DefType,
|
|
signalTab: 'buy' | 'sell',
|
|
positions: Map<string, { x: number; y: number }>,
|
|
callbacks: Pick<
|
|
StrategyFlowNodeData,
|
|
| 'onDelete'
|
|
| 'onUpdateCondition'
|
|
| 'onUpdateStochPairSecondary'
|
|
| 'onUpdatePriceExtremeFilterLevel'
|
|
| 'onUpdateInflection33FilterLevel'
|
|
| 'onUpdateStableStrategyFilterLevel'
|
|
| 'onDropTarget'
|
|
| 'onDragOverTarget'
|
|
| 'onDragLeaveTarget'
|
|
| 'onStartCandleTypesChange'
|
|
| 'onDeleteStart'
|
|
| 'onChangeLogicGateType'
|
|
>,
|
|
dragOverId: string | null = null,
|
|
orphans: LogicNode[] = [],
|
|
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
|
|
extraStartIds: string[] = [],
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): { nodes: Node[]; edges: Edge[] } {
|
|
const nodes: Node[] = [];
|
|
const edges: Edge[] = [];
|
|
|
|
const appendStart = (startId: string, branchRoot: LogicNode | null, defaultY: number) => {
|
|
const candleTypes = getStartCandleTypes(startMeta[startId]);
|
|
nodes.push({
|
|
id: startId,
|
|
type: 'start',
|
|
position: positions.get(startId) ?? { x: 0, y: defaultY },
|
|
data: {
|
|
label: 'START',
|
|
signalTab,
|
|
candleType: candleTypes[0],
|
|
candleTypes,
|
|
onStartCandleTypesChange: callbacks.onStartCandleTypesChange,
|
|
onDeleteStart: startId === START_NODE_ID ? undefined : callbacks.onDeleteStart,
|
|
onDropTarget: callbacks.onDropTarget,
|
|
onDragOverTarget: callbacks.onDragOverTarget,
|
|
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
|
canSourceConnect: true,
|
|
canTargetConnect: false,
|
|
} satisfies StrategyFlowNodeData,
|
|
draggable: true,
|
|
selectable: true,
|
|
});
|
|
|
|
if (branchRoot) {
|
|
layoutTree(
|
|
branchRoot,
|
|
startId,
|
|
1,
|
|
0,
|
|
1,
|
|
nodes,
|
|
edges,
|
|
positions,
|
|
def,
|
|
signalTab,
|
|
callbacks,
|
|
dragOverId,
|
|
);
|
|
}
|
|
};
|
|
|
|
appendStart(START_NODE_ID, root, 220);
|
|
extraStartIds.forEach((startId, index) => {
|
|
appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140);
|
|
});
|
|
|
|
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
|
|
|
return { nodes, edges };
|
|
}
|
|
|
|
export function getIndicatorPeriodLabel(indicator: string, def: DefType): string {
|
|
return formatIndicatorPeriodLabel(indicator, def);
|
|
}
|
|
|
|
/** 트리에서 노드 추출 (부모에서 제거 후 반환) */
|
|
export function extractNode(root: LogicNode, id: string): { tree: LogicNode | null; node: LogicNode | null } {
|
|
if (root.id === id) return { tree: null, node: root };
|
|
if (!root.children?.length) return { tree: root, node: null };
|
|
|
|
for (let i = 0; i < root.children.length; i++) {
|
|
const child = root.children[i];
|
|
if (child.id === id) {
|
|
return {
|
|
tree: { ...root, children: root.children.filter(c => c.id !== id) },
|
|
node: child,
|
|
};
|
|
}
|
|
const sub = extractNode(child, id);
|
|
if (sub.node) {
|
|
const newChildren = root.children.map(c => (c.id === child.id ? sub.tree! : c)).filter(Boolean) as LogicNode[];
|
|
return { tree: { ...root, children: newChildren }, node: sub.node };
|
|
}
|
|
}
|
|
return { tree: root, node: null };
|
|
}
|
|
|
|
export function findNodeInTree(root: LogicNode | null, id: string): LogicNode | null {
|
|
if (!root) return null;
|
|
if (root.id === id) return root;
|
|
for (const c of root.children ?? []) {
|
|
const hit = findNodeInTree(c, id);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 트리에서 nodeId의 직계 부모 ID (루트의 부모는 START) */
|
|
export function findParentIdInTree(
|
|
root: LogicNode,
|
|
nodeId: string,
|
|
parentId: string = START_NODE_ID,
|
|
): string | null {
|
|
if (root.id === nodeId) return parentId;
|
|
for (const c of root.children ?? []) {
|
|
const hit = findParentIdInTree(c, nodeId, root.id);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 연결 해제 시 미연결 목록으로 펼침 — Logic Expression·캔버스 모두 트리에서 제외 */
|
|
export function subtreeToFlatOrphans(node: LogicNode): LogicNode[] {
|
|
switch (node.type) {
|
|
case 'CONDITION':
|
|
return [{ ...node, children: undefined }];
|
|
case 'NOT': {
|
|
const inner = node.children?.[0];
|
|
return inner ? subtreeToFlatOrphans(inner) : [];
|
|
}
|
|
case 'AND':
|
|
case 'OR': {
|
|
const kids = node.children ?? [];
|
|
if (kids.length === 0) return [];
|
|
return kids.flatMap(subtreeToFlatOrphans);
|
|
}
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** 자식이 없는 AND/OR/NOT 제거 — 수식에서 (빈 AND) 등 방지 */
|
|
export function pruneEmptyGates(root: LogicNode | null): LogicNode | null {
|
|
if (!root) return null;
|
|
if (root.type === 'CONDITION') return root;
|
|
|
|
const prunedChildren = (root.children ?? [])
|
|
.map(c => pruneEmptyGates(c))
|
|
.filter((c): c is LogicNode => c != null);
|
|
|
|
if (root.type === 'NOT') {
|
|
if (prunedChildren.length === 0) return null;
|
|
return { ...root, children: [prunedChildren[0]] };
|
|
}
|
|
|
|
if (root.type === 'AND' || root.type === 'OR') {
|
|
if (prunedChildren.length === 0) return null;
|
|
return { ...root, children: prunedChildren };
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
function mergeOrphans(existing: LogicNode[], added: LogicNode[]): LogicNode[] {
|
|
const seen = new Set(existing.map(o => o.id));
|
|
const next = [...existing];
|
|
for (const o of added) {
|
|
if (!seen.has(o.id)) {
|
|
seen.add(o.id);
|
|
next.push(o);
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function resolveDisconnectEndpoints(
|
|
sourceId: string,
|
|
targetId: string,
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[],
|
|
extraRoots: Record<string, LogicNode | null>,
|
|
): { parentId: string; childId: string } | null {
|
|
const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null };
|
|
const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots);
|
|
if (resolved) return resolved;
|
|
|
|
if (isStartNodeId(sourceId)) {
|
|
const branchRoot = sourceId === START_NODE_ID ? root : extraRoots[sourceId] ?? null;
|
|
if (branchRoot?.id === targetId) {
|
|
return { parentId: sourceId, childId: targetId };
|
|
}
|
|
}
|
|
|
|
if (root) {
|
|
const parentInTree = findParentIdInTree(root, targetId);
|
|
if (parentInTree === sourceId) {
|
|
return { parentId: sourceId, childId: targetId };
|
|
}
|
|
}
|
|
|
|
for (const [startId, branchRoot] of Object.entries(extraRoots)) {
|
|
if (!branchRoot) continue;
|
|
const parentInTree = findParentIdInTree(branchRoot, targetId, startId);
|
|
if (parentInTree === sourceId) {
|
|
return { parentId: sourceId, childId: targetId };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function isDescendant(root: LogicNode, ancestorId: string, nodeId: string): boolean {
|
|
const ancestor = findNodeInTree(root, ancestorId);
|
|
if (!ancestor) return false;
|
|
return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId;
|
|
}
|
|
|
|
export function disconnectTreeEdge(
|
|
sourceId: string,
|
|
targetId: string,
|
|
root: LogicNode | null,
|
|
orphans: LogicNode[],
|
|
extraRoots: Record<string, LogicNode | null> = {},
|
|
): {
|
|
root: LogicNode | null;
|
|
orphans: LogicNode[];
|
|
extraRoots: Record<string, LogicNode | null>;
|
|
} {
|
|
const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans, extraRoots);
|
|
if (!endpoints) return { root, orphans, extraRoots };
|
|
|
|
const { parentId, childId } = endpoints;
|
|
if (isStartNodeId(childId)) return { root, orphans, extraRoots };
|
|
|
|
const inPrimary = root && (root.id === childId || !!findNodeInTree(root, childId));
|
|
const inExtraStartId = Object.entries(extraRoots).find(([, branch]) =>
|
|
branch && (branch.id === childId || !!findNodeInTree(branch, childId)),
|
|
)?.[0];
|
|
|
|
if (!inPrimary && !inExtraStartId) return { root, orphans, extraRoots };
|
|
|
|
if (isStartNodeId(parentId)) {
|
|
if (parentId === START_NODE_ID && root?.id === childId) {
|
|
return {
|
|
root: null,
|
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)),
|
|
extraRoots,
|
|
};
|
|
}
|
|
const branchRoot = extraRoots[parentId] ?? null;
|
|
if (branchRoot?.id === childId) {
|
|
const nextExtra = { ...extraRoots, [parentId]: null };
|
|
return {
|
|
root,
|
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(branchRoot)),
|
|
extraRoots: nextExtra,
|
|
};
|
|
}
|
|
}
|
|
|
|
const extractFrom = inPrimary ? root! : extraRoots[inExtraStartId!]!;
|
|
const { tree, node } = extractNode(extractFrom, childId);
|
|
if (!node) return { root, orphans, extraRoots };
|
|
|
|
if (inPrimary) {
|
|
return {
|
|
root: pruneEmptyGates(tree),
|
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
|
extraRoots,
|
|
};
|
|
}
|
|
|
|
return {
|
|
root,
|
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
|
extraRoots: {
|
|
...extraRoots,
|
|
[inExtraStartId!]: pruneEmptyGates(tree),
|
|
},
|
|
};
|
|
}
|