245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
import React, { memo, useCallback, useState } from 'react';
|
||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||
|
||
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 cond = node.condition;
|
||
const ind = cond?.indicatorType ?? 'COND';
|
||
const condType = cond?.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);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const showSettings = cond && hasNodeSettings(cond);
|
||
|
||
const handleSettingsChange = useCallback((next: NonNullable<typeof cond>) => {
|
||
d.onUpdateCondition?.(id, next);
|
||
}, [d, id]);
|
||
|
||
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' : ''}${settingsOpen ? ' se-flow-node--settings-open' : ''}`}
|
||
onDragOver={onDragOver}
|
||
onDragLeave={onDragLeave}
|
||
onDrop={onDrop}
|
||
>
|
||
<MultiSideHandles
|
||
nodeId={id}
|
||
targetOnly
|
||
canTargetConnect={d.canTargetConnect !== false}
|
||
/>
|
||
<div className="se-flow-cond-top">
|
||
<div className="se-flow-cond-badge">{ind}</div>
|
||
<div className="se-flow-cond-actions">
|
||
{showSettings && (
|
||
<button
|
||
type="button"
|
||
className="se-flow-settings"
|
||
title="지표 설정"
|
||
aria-label="지표 설정"
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
setSettingsOpen(v => !v);
|
||
}}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||
</div>
|
||
</div>
|
||
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
||
{settingsOpen && cond && d.def && (
|
||
<ConditionNodeSettings
|
||
condition={cond}
|
||
def={d.def}
|
||
onChange={handleSettingsChange}
|
||
onClose={() => setSettingsOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|