전략편집기 메뉴 추가

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
@@ -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>
);
});