전략편집기 메뉴 추가

This commit is contained in:
Macbook
2026-05-24 06:20:18 +09:00
parent bbe82e510b
commit ea39f7df27
24 changed files with 4897 additions and 29 deletions
+666
View File
@@ -0,0 +1,666 @@
import type { Connection, Edge, Node } from '@xyflow/react';
import type { LogicNode } from './strategyTypes';
import { nodeToText, type DefType } from './strategyEditorShared';
export const START_NODE_ID = '__strategy_start__';
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;
onDelete?: (id: string) => 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,
): LogicNode | null {
if (id === START_NODE_ID) return null;
const inTree = findNodeInTree(root, id);
if (inTree) return inTree;
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 (anchorId === START_NODE_ID) 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) 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 (anchorId === START_NODE_ID) 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[] = [],
): boolean {
if (!parentId || !childId || parentId === childId) return false;
if (childId === START_NODE_ID) return false;
const childOrphan = orphans.find(o => o.id === childId);
const parentOrphan = orphans.find(o => o.id === parentId);
if (childOrphan) {
if (parentId === START_NODE_ID) return true;
const parentNode = findNodeInTree(root, parentId);
if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false;
return true;
}
if (parentOrphan) {
if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false;
const childNode = findNodeInTree(root, childId);
if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false;
if (root && isDescendant(root, childId, parentId)) return false;
return true;
}
if (!root) return false;
const parentNode = parentId === START_NODE_ID ? null : findNodeInTree(root, parentId);
const childNode = findNodeInTree(root, childId);
if (parentId !== START_NODE_ID && !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 (parentId !== START_NODE_ID && isDescendant(root, childId, parentId)) return false;
return true;
}
/** 수동 연결·재연결 — 드래그 방향과 무관하게 부모→자식이 성립하면 유효 */
export function isValidStrategyConnection(
conn: Connection,
root: LogicNode | null,
orphans: LogicNode[] = [],
): boolean {
const { source, target } = conn;
if (!source || !target) return false;
return (
isValidStrategyConnectionDirected(source, target, root, orphans)
|| isValidStrategyConnectionDirected(target, source, root, orphans)
);
}
/** onConnect용 — 부모·자식 ID (드래그 시작 방향 우선, 불가 시 반대 방향) */
export function resolveStrategyConnection(
conn: Connection,
root: LogicNode | null,
orphans: LogicNode[] = [],
): { parentId: string; childId: string } | null {
const { source, target } = conn;
if (!source || !target) return null;
if (isValidStrategyConnectionDirected(source, target, root, orphans)) {
return { parentId: source, childId: target };
}
if (isValidStrategyConnectionDirected(target, source, root, orphans)) {
return { parentId: target, childId: source };
}
return null;
}
export function getNodeDimensions(nodeId: string): { w: number; h: number } {
return nodeId === START_NODE_ID
? { 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[],
): { 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,
) ?? resolveStrategyConnection(
{ source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null },
root,
orphans,
);
}
/** 드래그 연결 시 부모→자식 핸들 방향 */
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;
manual?: boolean;
};
export const FLOW_NODE_W = 200;
export const FLOW_NODE_H = 72;
export const FLOW_START_W = 100;
export const FLOW_START_H = 56;
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 = id === START_NODE_ID ? FLOW_START_W : FLOW_NODE_W;
const h = id === START_NODE_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 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);
if (binding?.manual && binding.sourceHandle && binding.targetHandle) {
return { ...edge, sourceHandle: binding.sourceHandle, targetHandle: binding.targetHandle };
}
const picked = pickHandleSides(edge.source, edge.target, positions);
const sourceHandle = binding?.sourceHandle ?? picked.sourceHandle;
const targetHandle = binding?.targetHandle ?? picked.targetHandle;
saved.set(edge.id, { sourceHandle, targetHandle, manual: binding?.manual });
return { ...edge, sourceHandle, targetHandle };
});
}
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' | '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,
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);
});
}
function appendOrphanFlowNodes(
orphans: LogicNode[],
nodes: Node[],
positions: Map<string, { x: number; y: number }>,
def: DefType,
signalTab: 'buy' | 'sell',
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | '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,
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' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
dragOverId: string | null = null,
orphans: LogicNode[] = [],
): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = [];
const edges: Edge[] = [];
nodes.push({
id: START_NODE_ID,
type: 'start',
position: positions.get(START_NODE_ID) ?? { x: 0, y: 220 },
data: {
label: 'START',
signalTab,
onDropTarget: callbacks.onDropTarget,
onDragOverTarget: callbacks.onDragOverTarget,
onDragLeaveTarget: callbacks.onDragLeaveTarget,
canSourceConnect: true,
canTargetConnect: false,
} satisfies StrategyFlowNodeData,
draggable: true,
selectable: true,
});
if (root) {
layoutTree(root, START_NODE_ID, 1, 0, 1, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
}
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
return { nodes, edges };
}
export function getIndicatorPeriodLabel(indicator: string, def: DefType): string {
switch (indicator) {
case 'RSI': return String(def.rsiPeriod);
case 'MACD': return `${def.macdFast}/${def.macdSlow}`;
case 'CCI': return String(def.cciPeriod);
case 'STOCHASTIC': return `${def.stochK}/${def.stochD}`;
case 'ADX': return String(def.adxPeriod);
case 'MA': return String(def.maLines[0] ?? 5);
case 'EMA': return String(def.maLines[0] ?? 5);
case 'BOLLINGER': return String(def.bbPeriod);
default: return '';
}
}
/** 트리에서 노드 추출 (부모에서 제거 후 반환) */
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,
orphans: LogicNode[],
): { parentId: string; childId: string } | null {
const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null };
const resolved = resolveStrategyConnection(conn, root, orphans);
if (resolved) return resolved;
if (sourceId === START_NODE_ID && root.id === targetId) {
return { parentId: START_NODE_ID, childId: targetId };
}
const parentInTree = findParentIdInTree(root, targetId);
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;
}
/** 연결선 제거 — 자식 서브트리를 트리에서 분리·미연결(고아)로 → Logic Expression 제외 */
export function disconnectTreeEdge(
sourceId: string,
targetId: string,
root: LogicNode | null,
orphans: LogicNode[],
): { root: LogicNode | null; orphans: LogicNode[] } {
if (!root) return { root, orphans };
const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans);
if (!endpoints) return { root, orphans };
const { parentId, childId } = endpoints;
if (childId === START_NODE_ID) return { root, orphans };
const childInTree = root.id === childId || !!findNodeInTree(root, childId);
if (!childInTree) return { root, orphans };
if (parentId === START_NODE_ID && root.id === childId) {
return {
root: null,
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)),
};
}
const { tree, node } = extractNode(root, childId);
if (!node) return { root, orphans };
return {
root: pruneEmptyGates(tree),
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
};
}