전략편집기 메뉴 추가
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
||||
|
||||
const LOGIC_COLORS: Record<string, string> = {
|
||||
AND: '#00aaff',
|
||||
OR: '#00d4ff',
|
||||
NOT: '#ffcc00',
|
||||
};
|
||||
|
||||
const SIDES: { side: HandleSide; position: Position }[] = [
|
||||
{ side: 'top', position: Position.Top },
|
||||
{ side: 'right', position: Position.Right },
|
||||
{ side: 'bottom', position: Position.Bottom },
|
||||
{ side: 'left', position: Position.Left },
|
||||
];
|
||||
|
||||
type ConnectHover = {
|
||||
handleId: string | null | undefined;
|
||||
handleType: 'source' | 'target';
|
||||
nodeId: string;
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
function MultiSideHandles({
|
||||
nodeId,
|
||||
sourceOnly = false,
|
||||
targetOnly = false,
|
||||
activeSourceSide = null,
|
||||
canSourceConnect = true,
|
||||
canTargetConnect = true,
|
||||
}: {
|
||||
nodeId: string;
|
||||
sourceOnly?: boolean;
|
||||
targetOnly?: boolean;
|
||||
activeSourceSide?: HandleSide | null;
|
||||
canSourceConnect?: boolean;
|
||||
canTargetConnect?: boolean;
|
||||
}) {
|
||||
const connectHover = useConnection((s): ConnectHover | null => {
|
||||
if (!s.inProgress || s.isValid === null || !s.toHandle) return null;
|
||||
return {
|
||||
nodeId: s.toHandle.nodeId,
|
||||
handleId: s.toHandle.id,
|
||||
handleType: s.toHandle.type,
|
||||
isValid: s.isValid,
|
||||
};
|
||||
});
|
||||
|
||||
const hoverClass = (handleId: string, handleType: 'source' | 'target') => {
|
||||
if (!connectHover) return '';
|
||||
if (connectHover.nodeId !== nodeId || connectHover.handleType !== handleType) return '';
|
||||
if (connectHover.handleId !== handleId) return '';
|
||||
return connectHover.isValid ? ' se-flow-handle--valid-hover' : ' se-flow-handle--invalid';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{SIDES.map(({ side, position }) => (
|
||||
<React.Fragment key={side}>
|
||||
{!targetOnly && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={position}
|
||||
id={`s-${side}`}
|
||||
isConnectable={canSourceConnect}
|
||||
className={`se-flow-handle se-flow-handle--src${
|
||||
!canSourceConnect ? ' se-flow-handle--disabled' : ''
|
||||
}${activeSourceSide === side && canSourceConnect ? ' se-flow-handle--active' : ''}${
|
||||
hoverClass(`s-${side}`, 'source')
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{!sourceOnly && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={position}
|
||||
id={`t-${side}`}
|
||||
isConnectable={canTargetConnect}
|
||||
className={`se-flow-handle se-flow-handle--tgt${
|
||||
!canTargetConnect ? ' se-flow-handle--disabled' : ''
|
||||
}${hoverClass(`t-${side}`, 'target')}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('application/json')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
d.onDragOverTarget?.(id, flowPos);
|
||||
}, [d, id, screenToFlowPosition]);
|
||||
|
||||
const onDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
const rel = e.relatedTarget as Node | null;
|
||||
if (rel && e.currentTarget.contains(rel)) return;
|
||||
d.onDragLeaveTarget?.(id);
|
||||
}, [d, id]);
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
d.onDropTarget?.(id, payload, flowPos);
|
||||
} catch { /* ignore */ }
|
||||
}, [d, id, screenToFlowPosition]);
|
||||
|
||||
return { onDragOver, onDragLeave, onDrop };
|
||||
}
|
||||
|
||||
export const StartNode = memo(function StartNode({ data }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(START_NODE_ID, d);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--start${isSell ? ' se-flow-node--sell-mode' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={START_NODE_ID}
|
||||
sourceOnly
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-start-dot" />
|
||||
<span>START</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const node = d.logicNode!;
|
||||
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--logic${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
style={{ '--se-gate-color': color } as React.CSSProperties}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={id}
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-gate-head">
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
</div>
|
||||
{(node.children ?? []).length === 0 && (
|
||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||
)}
|
||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, selected }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const node = d.logicNode!;
|
||||
const ind = node.condition?.indicatorType ?? 'COND';
|
||||
const condType = node.condition?.conditionType ?? '';
|
||||
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
||||
const tone = isCross ? 'cross' : 'value';
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={id}
|
||||
targetOnly
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-cond-badge">{ind}</div>
|
||||
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
||||
import { getIndicatorPaletteCategory } from './paletteCategories';
|
||||
|
||||
interface Props {
|
||||
buyCondition: LogicNode | null;
|
||||
sellCondition: LogicNode | null;
|
||||
orphanCount: number;
|
||||
def: DefType;
|
||||
}
|
||||
|
||||
function renderCondition(node: LogicNode, def: DefType): React.ReactNode {
|
||||
const full = nodeToText(node, def);
|
||||
const ind = node.condition?.indicatorType ?? '';
|
||||
const tail = ind && full.startsWith(`${ind} -`) ? full.slice(ind.length) : full;
|
||||
const cat = getIndicatorPaletteCategory(ind);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{ind ? (
|
||||
<>
|
||||
<span className={`se-formula-ind se-formula-ind--${cat}`}>{ind}</span>
|
||||
{tail ? <span className="se-formula-detail">{tail}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="se-formula-detail">{full || '?'}</span>
|
||||
)}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
||||
if (node.type === 'CONDITION') return renderCondition(node, def);
|
||||
|
||||
if (node.type === 'AND') {
|
||||
const children = node.children ?? [];
|
||||
if (children.length === 0) return <span className="se-formula-empty">(빈 AND)</span>;
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{children.map((child, i) => (
|
||||
<React.Fragment key={child.id}>
|
||||
{i > 0 ? <span className="se-formula-logic se-formula-logic--and"> AND </span> : null}
|
||||
{renderNode(child, def)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'OR') {
|
||||
const children = node.children ?? [];
|
||||
if (children.length === 0) return <span className="se-formula-empty">(빈 OR)</span>;
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{children.map((child, i) => (
|
||||
<React.Fragment key={child.id}>
|
||||
{i > 0 ? <span className="se-formula-logic se-formula-logic--or"> OR </span> : null}
|
||||
{renderNode(child, def)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'NOT') {
|
||||
const child = node.children?.[0];
|
||||
return child ? (
|
||||
<>
|
||||
<span className="se-formula-logic se-formula-logic--not">NOT </span>
|
||||
{renderNode(child, def)}
|
||||
</>
|
||||
) : (
|
||||
<span className="se-formula-empty">(빈 NOT)</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LogicExpressionPreview({
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
orphanCount,
|
||||
def,
|
||||
}: Props) {
|
||||
const hasBody = buyCondition || sellCondition;
|
||||
|
||||
return (
|
||||
<div className="se-terminal-text se-terminal-text--rich">
|
||||
{!hasBody && (
|
||||
<span className="se-formula-empty">(연결된 조건을 구성하세요)</span>
|
||||
)}
|
||||
{buyCondition && (
|
||||
<div className="se-formula-line">
|
||||
<span className="se-formula-signal se-formula-signal--buy">[매수]</span>
|
||||
{' '}
|
||||
{renderNode(buyCondition, def)}
|
||||
</div>
|
||||
)}
|
||||
{sellCondition && (
|
||||
<div className="se-formula-line">
|
||||
<span className="se-formula-signal se-formula-signal--sell">[매도]</span>
|
||||
{' '}
|
||||
{renderNode(sellCondition, def)}
|
||||
</div>
|
||||
)}
|
||||
{orphanCount > 0 && (
|
||||
<div className="se-formula-line se-formula-comment">
|
||||
{'// 미연결 요소 '}{orphanCount}{'개 — 전략 코드 미포함'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getNodesBounds, useReactFlow, useStore } from '@xyflow/react';
|
||||
import { START_NODE_ID } from '../../utils/strategyFlowLayout';
|
||||
|
||||
type Props = {
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function MultiSelectionDeleteButton({ onDelete }: Props) {
|
||||
const { getNodes, flowToScreenPosition } = useReactFlow();
|
||||
|
||||
const selectionKey = useStore(useCallback((state) => {
|
||||
const selected = state.nodes.filter(n => n.selected && n.id !== START_NODE_ID);
|
||||
if (selected.length < 2) return null;
|
||||
return selected
|
||||
.map(n => `${n.id}:${Math.round(n.position.x)}:${Math.round(n.position.y)}`)
|
||||
.join('|');
|
||||
}, []));
|
||||
|
||||
if (!selectionKey) return null;
|
||||
|
||||
const selectedNodes = getNodes().filter(n => n.selected && n.id !== START_NODE_ID);
|
||||
if (selectedNodes.length < 2) return null;
|
||||
|
||||
const bounds = getNodesBounds(selectedNodes);
|
||||
const anchor = flowToScreenPosition({
|
||||
x: bounds.x + bounds.width,
|
||||
y: bounds.y,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="se-selection-delete"
|
||||
title="선택 항목 모두 삭제 (Logic Expression에서 제외)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: anchor.x + 6,
|
||||
top: anchor.y - 6,
|
||||
transform: 'translate(0, -100%)',
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface PaletteDragPayload {
|
||||
type: 'operator' | 'indicator';
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: 'operator' | 'indicator';
|
||||
value: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
color?: string;
|
||||
period?: string;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function PaletteChip({ type, value, label, desc, color, period, onAdd }: Props) {
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
const payload: PaletteDragPayload = { type, value, label };
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}`}
|
||||
onDragStart={onDragStart}
|
||||
onClick={onAdd}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onAdd(); }}
|
||||
>
|
||||
<div className="se-palette-card-head">
|
||||
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
||||
<div className="se-palette-card-meta">
|
||||
<span className="se-palette-card-name">{label}</span>
|
||||
{desc ? <span className="se-palette-card-desc">{desc}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{period ? (
|
||||
<div className="se-palette-card-period">
|
||||
<span>기간</span>
|
||||
<input readOnly value={period} className="se-palette-period-input" tabIndex={-1} />
|
||||
<span className="se-palette-sync" title="차트 지표 설정과 동기화">⟳</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type OnNodesChange,
|
||||
type OnSelectionChangeFunc,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type { Theme } from '../../types/index';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import {
|
||||
addChild,
|
||||
deleteNode,
|
||||
mergeAtRoot,
|
||||
makeNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
applyEdgeHandles,
|
||||
buildConnectionFromPositions,
|
||||
canAcceptPaletteAsParent,
|
||||
connectionSidesFromDrop,
|
||||
disconnectTreeEdge,
|
||||
extractNode,
|
||||
findNodeAtFlowPosition,
|
||||
findNodeInTree,
|
||||
FLOW_NODE_H,
|
||||
FLOW_NODE_W,
|
||||
getNodeDimensions,
|
||||
isDescendant,
|
||||
isOrphanNode,
|
||||
isPaletteConnectTarget,
|
||||
isValidStrategyConnection,
|
||||
resolveStrategyConnection,
|
||||
resolveOrphanDragConnection,
|
||||
logicNodeToFlow,
|
||||
spawnPositionNearAnchor,
|
||||
type EdgeHandleBinding,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||
import { getMinimapColors } from './minimapTheme';
|
||||
|
||||
const nodeTypes = {
|
||||
start: StartNode,
|
||||
logic: LogicGateNode,
|
||||
condition: ConditionFlowNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
strategy: StrategyFlowEdge,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
root: LogicNode | null;
|
||||
orphans: LogicNode[];
|
||||
onOrphansChange: (nodes: LogicNode[]) => void;
|
||||
def: DefType;
|
||||
signalTab: 'buy' | 'sell';
|
||||
onChange: (root: LogicNode | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function collectTreeIds(node: LogicNode | null): string[] {
|
||||
if (!node) return [];
|
||||
const ids = [node.id];
|
||||
for (const c of node.children ?? []) ids.push(...collectTreeIds(c));
|
||||
return ids;
|
||||
}
|
||||
|
||||
function saveEdgeHandles(
|
||||
handles: Map<string, EdgeHandleBinding>,
|
||||
edgeId: string,
|
||||
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
|
||||
manual = true,
|
||||
) {
|
||||
handles.set(edgeId, {
|
||||
sourceHandle: conn.sourceHandle,
|
||||
targetHandle: conn.targetHandle,
|
||||
manual,
|
||||
});
|
||||
}
|
||||
|
||||
function applyTreeConnection(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
) {
|
||||
if (sourceId === START_NODE_ID) {
|
||||
const { tree, node } = extractNode(root, targetId);
|
||||
if (!node) return;
|
||||
onChange(node);
|
||||
if (tree && tree.children?.length) {
|
||||
onChange(mergeAtRoot(node, tree, false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = findNodeInTree(root, sourceId);
|
||||
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
|
||||
if (isDescendant(root, targetId, sourceId)) return;
|
||||
|
||||
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
||||
if (!moved) return;
|
||||
const base = afterRemove ?? root;
|
||||
if (sourceId === targetId) return;
|
||||
onChange(addChild(base, sourceId, moved));
|
||||
}
|
||||
|
||||
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
|
||||
const ids = new Set(edges.map(e => e.id));
|
||||
for (const id of handles.keys()) {
|
||||
if (!ids.has(id)) handles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function connectOrphanIntoTree(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
): void {
|
||||
const sourceId = conn.source!;
|
||||
if (sourceId === START_NODE_ID) {
|
||||
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
onChange(addChild(root, sourceId, orphan));
|
||||
}
|
||||
|
||||
function connectOrphanAsParent(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
): void {
|
||||
if (!root) return;
|
||||
const { tree, node: moved } = extractNode(root, conn.target!);
|
||||
if (!moved) return;
|
||||
const isOp = ['AND', 'OR', 'NOT'].includes(orphan.type);
|
||||
const combined = { ...orphan, children: [...(orphan.children ?? []), moved] };
|
||||
onChange(tree ? mergeAtRoot(tree, combined, isOp) : combined);
|
||||
}
|
||||
|
||||
function StrategyEditorCanvasInner({
|
||||
theme,
|
||||
root,
|
||||
orphans,
|
||||
onOrphansChange,
|
||||
def,
|
||||
signalTab,
|
||||
onChange,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: Props) {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow();
|
||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
|
||||
const structureKeyRef = useRef('');
|
||||
const orphanKeyRef = useRef('');
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
nodesRef.current = nodes;
|
||||
edgesRef.current = edges;
|
||||
|
||||
const treeStructureKey = useMemo(
|
||||
() => `${signalTab}:${collectTreeIds(root).join('|')}`,
|
||||
[root, signalTab],
|
||||
);
|
||||
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
||||
|
||||
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
|
||||
const treeLinkedIds = useMemo(() => {
|
||||
const ids = new Set<string>([START_NODE_ID]);
|
||||
for (const id of collectTreeIds(root)) ids.add(id);
|
||||
return ids;
|
||||
}, [root]);
|
||||
|
||||
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
||||
|
||||
const minimapNodeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
if (node.type === 'condition') return minimapColors.condition;
|
||||
return minimapColors.logic;
|
||||
}, [minimapColors]);
|
||||
|
||||
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
return minimapColors.stroke;
|
||||
}, [minimapColors]);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
if (id === START_NODE_ID) return;
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== id));
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
if (root.id === id) {
|
||||
onChange(null);
|
||||
onSelectNode(null);
|
||||
positionsRef.current.delete(id);
|
||||
return;
|
||||
}
|
||||
const next = deleteNode(root, id);
|
||||
onChange(next);
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId]);
|
||||
|
||||
const deleteSelectedNodes = useCallback(() => {
|
||||
const ids = nodesRef.current
|
||||
.filter(n => n.selected && n.id !== START_NODE_ID)
|
||||
.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
const orphanIds = new Set(ids.filter(id => isOrphanNode(orphans, id)));
|
||||
if (orphanIds.size) {
|
||||
onOrphansChange(orphans.filter(o => !orphanIds.has(o.id)));
|
||||
orphanIds.forEach(id => positionsRef.current.delete(id));
|
||||
}
|
||||
|
||||
const treeIds = ids.filter(id => !orphanIds.has(id));
|
||||
if (treeIds.length && root) {
|
||||
let tree: LogicNode | null = root;
|
||||
for (const id of treeIds) {
|
||||
if (!tree) break;
|
||||
if (tree.id === id) {
|
||||
tree = null;
|
||||
positionsRef.current.delete(id);
|
||||
continue;
|
||||
}
|
||||
const next = deleteNode(tree, id);
|
||||
if (next !== tree) positionsRef.current.delete(id);
|
||||
tree = next;
|
||||
}
|
||||
onChange(tree);
|
||||
}
|
||||
onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode]);
|
||||
|
||||
const clearDropPreview = useCallback(() => {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: false,
|
||||
activeSourceSide: undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateDropPreview = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
const canParent = canAcceptPaletteAsParent(anchorId, root);
|
||||
if (!canParent) {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: { ...n.data, dragOver: false, activeSourceSide: undefined },
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const dim = getNodeDimensions(anchorId);
|
||||
const { sourceSide } = connectionSidesFromDrop(anchor.position, dim, flowPos);
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: n.id === anchorId,
|
||||
activeSourceSide: n.id === anchorId ? sourceSide : undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes, root]);
|
||||
|
||||
const orphanDragTargetRef = useRef<{ parentId: string; childId: string } | null>(null);
|
||||
|
||||
const updateOrphanConnectPreview = useCallback((
|
||||
resolved: { parentId: string; childId: string },
|
||||
draggedOrphanId: string,
|
||||
orphanPos: { x: number; y: number },
|
||||
) => {
|
||||
const { parentId, childId } = resolved;
|
||||
const nodesSnapshot = nodesRef.current;
|
||||
const parentNode = nodesSnapshot.find(n => n.id === parentId);
|
||||
const childNode = nodesSnapshot.find(n => n.id === childId);
|
||||
if (!parentNode || !childNode) return;
|
||||
|
||||
const parentDim = getNodeDimensions(parentId);
|
||||
const childDim = getNodeDimensions(childId);
|
||||
const parentPos = parentId === draggedOrphanId ? orphanPos : parentNode.position;
|
||||
const childPos = childId === draggedOrphanId ? orphanPos : childNode.position;
|
||||
const childCenter = {
|
||||
x: childPos.x + childDim.w / 2,
|
||||
y: childPos.y + childDim.h / 2,
|
||||
};
|
||||
const { sourceSide } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
|
||||
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: n.id === parentId,
|
||||
activeSourceSide: n.id === parentId ? sourceSide : undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const addOrphanAt = useCallback((
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
positionsRef.current.set(newNode.id, {
|
||||
x: flowPos.x - FLOW_NODE_W / 2,
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
});
|
||||
onOrphansChange([...orphans, newNode]);
|
||||
}, [orphans, onOrphansChange, signalTab, def]);
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
const anchorDim = getNodeDimensions(anchorId);
|
||||
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
|
||||
positionsRef.current.set(newNode.id, spawnPositionNearAnchor(anchor.position, anchorDim, sourceSide));
|
||||
|
||||
const saveAttachHandles = (parentId: string) => {
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
return;
|
||||
}
|
||||
if (anchorId === START_NODE_ID) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorNode = findNodeInTree(root, anchorId);
|
||||
if (anchorNode?.type === 'CONDITION') {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAcceptPaletteAsParent(anchorId, root)) return;
|
||||
|
||||
onChange(addChild(root, anchorId, newNode));
|
||||
saveAttachHandles(anchorId);
|
||||
}, [root, signalTab, def, onChange]);
|
||||
|
||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
updateDropPreview(anchorId, flowPos);
|
||||
}, [updateDropPreview]);
|
||||
|
||||
const handleDragLeaveTarget = useCallback((anchorId: string) => {
|
||||
setNodes(nds => nds.map(n => (
|
||||
n.id === anchorId
|
||||
? { ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined } }
|
||||
: n
|
||||
)));
|
||||
}, [setNodes]);
|
||||
|
||||
const handleDropTarget = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
clearDropPreview();
|
||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||
applyDropWithAttach(anchorId, data, flowPos);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
}), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
root,
|
||||
def,
|
||||
signalTab,
|
||||
positionsRef.current,
|
||||
flowCallbacks,
|
||||
null,
|
||||
orphans,
|
||||
), [root, def, signalTab, flowCallbacks, orphans]);
|
||||
|
||||
useEffect(() => {
|
||||
positionsRef.current = new Map();
|
||||
edgeHandlesRef.current = new Map();
|
||||
structureKeyRef.current = '';
|
||||
orphanKeyRef.current = '';
|
||||
onOrphansChange([]);
|
||||
}, [signalTab, onOrphansChange]);
|
||||
|
||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||
useEffect(() => {
|
||||
for (const n of nodesRef.current) {
|
||||
positionsRef.current.set(n.id, n.position);
|
||||
}
|
||||
|
||||
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
|
||||
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
|
||||
pruneEdgeHandles(edgeHandlesRef.current, mergedEdges);
|
||||
const structureChanged = structureKeyRef.current !== treeStructureKey;
|
||||
const orphansChanged = orphanKeyRef.current !== orphanKey;
|
||||
structureKeyRef.current = treeStructureKey;
|
||||
orphanKeyRef.current = orphanKey;
|
||||
|
||||
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
|
||||
const prev = nodesRef.current.find(p => p.id === n.id);
|
||||
return { ...n, selected: prev?.selected ?? false };
|
||||
}));
|
||||
|
||||
if (structureChanged || orphansChanged) {
|
||||
syncNodesFromLayout();
|
||||
setEdges(mergedEdges);
|
||||
if (structureChanged) {
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.4, duration: 200 });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setNodes(prev => prev.map(n => {
|
||||
const fresh = layoutNodes.find(ln => ln.id === n.id);
|
||||
if (!fresh) return n;
|
||||
return { ...n, data: fresh.data };
|
||||
}));
|
||||
setEdges(mergedEdges);
|
||||
}
|
||||
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView]);
|
||||
|
||||
const handleDisconnectEdge = useCallback((edgeId: string) => {
|
||||
const edge = edgesRef.current.find(e => e.id === edgeId);
|
||||
if (!edge?.source || !edge.target) return;
|
||||
|
||||
edgeHandlesRef.current.delete(edgeId);
|
||||
const { root: nextRoot, orphans: nextOrphans } = disconnectTreeEdge(
|
||||
edge.source,
|
||||
edge.target,
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
|
||||
if (nextRoot === root && nextOrphans.length === orphans.length) return;
|
||||
|
||||
onChange(nextRoot);
|
||||
onOrphansChange(nextOrphans);
|
||||
onSelectNode(null);
|
||||
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
edgeDisconnectRef.current = handleDisconnectEdge;
|
||||
return () => {
|
||||
edgeDisconnectRef.current = null;
|
||||
};
|
||||
}, [handleDisconnectEdge]);
|
||||
|
||||
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
||||
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
|
||||
if (picked.length === 1) onSelectNode(picked[0].id);
|
||||
else onSelectNode(null);
|
||||
if (selEdges.length === 1) {
|
||||
// 연결선만 선택된 경우 노드 선택 해제
|
||||
if (picked.length === 0) onSelectNode(null);
|
||||
}
|
||||
}, [onSelectNode]);
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(conn: Connection | Edge) => isValidStrategyConnection(
|
||||
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
|
||||
root,
|
||||
orphans,
|
||||
),
|
||||
[root, orphans],
|
||||
);
|
||||
|
||||
const applyResolvedConnection = useCallback((
|
||||
parentId: string,
|
||||
childId: string,
|
||||
conn: Connection,
|
||||
) => {
|
||||
const wired: Connection = { ...conn, source: parentId, target: childId };
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${childId}`, wired);
|
||||
|
||||
const childOrphan = orphans.find(o => o.id === childId);
|
||||
const parentOrphan = orphans.find(o => o.id === parentId);
|
||||
|
||||
if (childOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== childId));
|
||||
connectOrphanIntoTree(wired, childOrphan, root, onChange);
|
||||
return;
|
||||
}
|
||||
if (parentOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== parentId));
|
||||
connectOrphanAsParent(wired, parentOrphan, root, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root) return;
|
||||
applyTreeConnection(parentId, childId, root, onChange);
|
||||
}, [root, orphans, onChange, onOrphansChange]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
const positionChanges = changes.filter(
|
||||
(ch): ch is Extract<typeof ch, { type: 'position' }> => ch.type === 'position' && !!ch.position,
|
||||
);
|
||||
const startChange = positionChanges.find(ch => ch.id === START_NODE_ID);
|
||||
const otherTreePositionChanges = positionChanges.filter(
|
||||
ch => ch.id !== START_NODE_ID && treeLinkedIds.has(ch.id),
|
||||
);
|
||||
|
||||
let startGroupDelta: { dx: number; dy: number } | null = null;
|
||||
const startPos = startChange?.position;
|
||||
if (startChange && startPos && otherTreePositionChanges.length === 0) {
|
||||
const prev = positionsRef.current.get(START_NODE_ID);
|
||||
if (prev) {
|
||||
startGroupDelta = {
|
||||
dx: startPos.x - prev.x,
|
||||
dy: startPos.y - prev.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onNodesChangeBase(changes);
|
||||
|
||||
let dragEnded = false;
|
||||
|
||||
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
|
||||
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
|
||||
for (const id of treeLinkedIds) {
|
||||
if (id === START_NODE_ID) continue;
|
||||
const p = positionsRef.current.get(id);
|
||||
if (p) {
|
||||
positionsRef.current.set(id, {
|
||||
x: p.x + startGroupDelta.dx,
|
||||
y: p.y + startGroupDelta.dy,
|
||||
});
|
||||
}
|
||||
}
|
||||
setNodes(nds => nds.map(n => {
|
||||
if (!treeLinkedIds.has(n.id)) return n;
|
||||
const p = positionsRef.current.get(n.id);
|
||||
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
|
||||
}));
|
||||
if (startChange?.dragging === false) dragEnded = true;
|
||||
}
|
||||
|
||||
for (const ch of positionChanges) {
|
||||
if (!ch.position) continue;
|
||||
if (startGroupDelta && treeLinkedIds.has(ch.id)) continue;
|
||||
|
||||
if (isOrphanNode(orphans, ch.id)) {
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
|
||||
if (ch.dragging === true) {
|
||||
const nodesSnapshot = nodesRef.current.map(n => (
|
||||
n.id === ch.id
|
||||
? { id: n.id, position: ch.position! }
|
||||
: { id: n.id, position: n.position }
|
||||
));
|
||||
const resolved = resolveOrphanDragConnection(
|
||||
ch.id,
|
||||
ch.position,
|
||||
nodesSnapshot,
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
orphanDragTargetRef.current = resolved;
|
||||
if (resolved) {
|
||||
updateOrphanConnectPreview(resolved, ch.id, ch.position);
|
||||
} else {
|
||||
clearDropPreview();
|
||||
}
|
||||
} else if (ch.dragging === false) {
|
||||
const pending = orphanDragTargetRef.current;
|
||||
orphanDragTargetRef.current = null;
|
||||
clearDropPreview();
|
||||
if (pending) {
|
||||
const conn = buildConnectionFromPositions(
|
||||
pending.parentId,
|
||||
pending.childId,
|
||||
positionsRef.current,
|
||||
);
|
||||
applyResolvedConnection(pending.parentId, pending.childId, conn);
|
||||
}
|
||||
dragEnded = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
if (ch.dragging === false) dragEnded = true;
|
||||
}
|
||||
|
||||
if (dragEnded) {
|
||||
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
|
||||
}
|
||||
}, [
|
||||
onNodesChangeBase,
|
||||
setNodes,
|
||||
setEdges,
|
||||
treeLinkedIds,
|
||||
orphans,
|
||||
root,
|
||||
clearDropPreview,
|
||||
updateOrphanConnectPreview,
|
||||
applyResolvedConnection,
|
||||
]);
|
||||
|
||||
const onConnect = useCallback((conn: Connection) => {
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
||||
if (!resolved) return;
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
|
||||
}, [root, orphans, applyResolvedConnection]);
|
||||
|
||||
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
||||
const resolved = resolveStrategyConnection(newConnection, root, orphans);
|
||||
if (!resolved) return;
|
||||
|
||||
edgeHandlesRef.current.delete(oldEdge.id);
|
||||
|
||||
const endpointsChanged = oldEdge.source !== resolved.parentId || oldEdge.target !== resolved.childId;
|
||||
if (endpointsChanged) {
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, newConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection);
|
||||
setEdges(prev => prev.map(e => (
|
||||
e.id === oldEdge.id
|
||||
? {
|
||||
...e,
|
||||
sourceHandle: newConnection.sourceHandle,
|
||||
targetHandle: newConnection.targetHandle,
|
||||
}
|
||||
: e
|
||||
)));
|
||||
}, [root, orphans, applyResolvedConnection, setEdges]);
|
||||
|
||||
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
||||
|
||||
const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => {
|
||||
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
|
||||
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
|
||||
return { mode: 'connect' as const, anchorId: hit.id };
|
||||
}
|
||||
return { mode: 'orphan' as const };
|
||||
}, [root, orphans]);
|
||||
|
||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
clearDropPreview();
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, resolvePaletteDrop]);
|
||||
|
||||
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPaletteDrag(e)) return;
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
updateDropPreview(drop.anchorId, flowPos);
|
||||
} else {
|
||||
clearDropPreview();
|
||||
}
|
||||
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
|
||||
|
||||
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const rel = e.relatedTarget as Element | null;
|
||||
if (rel && e.currentTarget.contains(rel)) return;
|
||||
clearDropPreview();
|
||||
}, [clearDropPreview]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
|
||||
const selectedEdge = edgesRef.current.find(ed => ed.selected);
|
||||
if (selectedEdge) {
|
||||
e.preventDefault();
|
||||
handleDisconnectEdge(selectedEdge.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = nodesRef.current.filter(n => n.selected && n.id !== START_NODE_ID).length;
|
||||
if (selectedCount > 1) {
|
||||
e.preventDefault();
|
||||
deleteSelectedNodes();
|
||||
} else if (selectedNodeId && selectedNodeId !== START_NODE_ID) {
|
||||
e.preventDefault();
|
||||
handleDelete(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab}`}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onReconnect={onReconnect}
|
||||
onDrop={onPaneDrop}
|
||||
onDragOver={onPaneDragOver}
|
||||
onDragLeave={onPaneDragLeave}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
edgesReconnectable
|
||||
reconnectRadius={24}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.8}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
selectionOnDrag
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={[1, 2]}
|
||||
selectNodesOnDrag={false}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
deleteKeyCode={null}
|
||||
defaultEdgeOptions={{
|
||||
type: 'strategy',
|
||||
className: 'se-flow-edge',
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={24} size={1} className="se-flow-bg" />
|
||||
<Controls className="se-flow-controls" showInteractive={false} />
|
||||
<MiniMap
|
||||
className="se-flow-minimap"
|
||||
zoomable
|
||||
pannable
|
||||
nodeStrokeWidth={2}
|
||||
bgColor={minimapColors.bg}
|
||||
maskColor={minimapColors.mask}
|
||||
maskStrokeColor={minimapColors.maskStroke}
|
||||
nodeColor={minimapNodeColor}
|
||||
nodeStrokeColor={minimapNodeStrokeColor}
|
||||
/>
|
||||
<Panel position="top-left" className="se-canvas-toolbar">
|
||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
||||
<span className="se-canvas-toolbar-hint">
|
||||
다중 선택 × 일괄삭제 · 연결선 × 끊기 · Del 삭제
|
||||
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
|
||||
</span>
|
||||
</Panel>
|
||||
{!root && (
|
||||
<Panel position="top-center" className="se-canvas-empty">
|
||||
우측에서 지표·연산자를 드래그하거나 클릭하여 조건을 구성하세요
|
||||
</Panel>
|
||||
)}
|
||||
<MultiSelectionDeleteButton onDelete={deleteSelectedNodes} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyEditorCanvas(props: Props) {
|
||||
return <StrategyEditorCanvasInner {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
|
||||
export function StrategyFlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
}: EdgeProps) {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={`se-flow-edge${selected ? ' se-flow-edge--selected' : ''}`}
|
||||
style={{ strokeWidth: selected ? 3 : 2 }}
|
||||
/>
|
||||
{selected && edgeDisconnectRef.current && (
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-edge-del"
|
||||
title="연결 끊기 (전략 코드에서 제외)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
edgeDisconnectRef.current?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Theme } from '../../types/index';
|
||||
|
||||
export function getMinimapColors(theme: Theme, signalTab: 'buy' | 'sell') {
|
||||
const sell = signalTab === 'sell';
|
||||
if (theme === 'light') {
|
||||
return {
|
||||
bg: 'rgba(255, 255, 255, 0.98)',
|
||||
mask: sell ? 'rgba(198, 40, 40, 0.08)' : 'rgba(25, 118, 210, 0.08)',
|
||||
maskStroke: sell ? 'rgba(198, 40, 40, 0.45)' : 'rgba(25, 118, 210, 0.45)',
|
||||
start: 'rgba(245, 127, 17, 0.9)',
|
||||
condition: sell ? 'rgba(198, 40, 40, 0.75)' : 'rgba(25, 118, 210, 0.72)',
|
||||
logic: sell ? 'rgba(198, 40, 40, 0.5)' : 'rgba(25, 118, 210, 0.48)',
|
||||
stroke: sell ? 'rgba(198, 40, 40, 0.85)' : 'rgba(25, 118, 210, 0.85)',
|
||||
};
|
||||
}
|
||||
if (theme === 'blue') {
|
||||
return {
|
||||
bg: 'rgba(12, 25, 41, 0.96)',
|
||||
mask: sell ? 'rgba(255, 85, 85, 0.1)' : 'rgba(94, 181, 255, 0.1)',
|
||||
maskStroke: sell ? 'rgba(255, 85, 85, 0.55)' : 'rgba(94, 181, 255, 0.55)',
|
||||
start: 'rgba(255, 213, 79, 0.9)',
|
||||
condition: sell ? 'rgba(255, 85, 85, 0.82)' : 'rgba(94, 181, 255, 0.78)',
|
||||
logic: sell ? 'rgba(255, 85, 85, 0.52)' : 'rgba(94, 181, 255, 0.55)',
|
||||
stroke: sell ? 'rgba(255, 120, 140, 0.9)' : 'rgba(94, 181, 255, 0.85)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'rgba(26, 27, 38, 0.96)',
|
||||
mask: sell ? 'rgba(247, 118, 142, 0.1)' : 'rgba(122, 162, 247, 0.1)',
|
||||
maskStroke: sell ? 'rgba(247, 118, 142, 0.55)' : 'rgba(122, 162, 247, 0.55)',
|
||||
start: 'rgba(230, 194, 0, 0.9)',
|
||||
condition: sell ? 'rgba(247, 118, 142, 0.82)' : 'rgba(122, 162, 247, 0.78)',
|
||||
logic: sell ? 'rgba(247, 118, 142, 0.52)' : 'rgba(122, 162, 247, 0.55)',
|
||||
stroke: sell ? 'rgba(247, 118, 142, 0.9)' : 'rgba(122, 162, 247, 0.85)',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 팔레트·Logic Expression 공통 — 지표 카테고리 */
|
||||
|
||||
export const BAND_INDICATORS = new Set(['MA', 'EMA', 'BOLLINGER', 'ICHIMOKU']);
|
||||
|
||||
export type PaletteIndicatorCategory = 'band' | 'ind';
|
||||
|
||||
export function getIndicatorPaletteCategory(indicatorType: string): PaletteIndicatorCategory {
|
||||
return BAND_INDICATORS.has(indicatorType) ? 'band' : 'ind';
|
||||
}
|
||||
|
||||
export type LogicOperatorKind = 'and' | 'or' | 'not';
|
||||
|
||||
export function logicOperatorKind(type: string): LogicOperatorKind | null {
|
||||
if (type === 'AND') return 'and';
|
||||
if (type === 'OR') return 'or';
|
||||
if (type === 'NOT') return 'not';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** 연결선 끊기 — 커스텀 엣지 컴포넌트에서 캔버스 핸들러 참조 */
|
||||
export const edgeDisconnectRef = {
|
||||
current: null as ((edgeId: string) => void) | null,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
type Axis = 'horizontal' | 'vertical';
|
||||
|
||||
export function usePanelResize(
|
||||
axis: Axis,
|
||||
onResize: (next: number) => void,
|
||||
getCurrent: () => number,
|
||||
min: number,
|
||||
max: number,
|
||||
onCommit?: (value: number) => void,
|
||||
) {
|
||||
return useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const start = getCurrent();
|
||||
const cursor = axis === 'vertical' ? 'col-resize' : 'row-resize';
|
||||
|
||||
document.body.style.cursor = cursor;
|
||||
document.body.style.userSelect = 'none';
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.add('se-splitter--active');
|
||||
const splitter = e.currentTarget;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = axis === 'vertical' ? ev.clientX - startX : startY - ev.clientY;
|
||||
onResize(clamp(start + delta, min, max));
|
||||
};
|
||||
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
splitter.releasePointerCapture(ev.pointerId);
|
||||
splitter.classList.remove('se-splitter--active');
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
onCommit?.(getCurrent());
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, [axis, onResize, getCurrent, min, max, onCommit]);
|
||||
}
|
||||
|
||||
export function readStoredSize(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeSize(key: string, value: number): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(Math.round(value)));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
Reference in New Issue
Block a user