전략편집기 메뉴 추가
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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user