Files
goldenChart/frontend/src/components/strategyEditor/FlowNodes.tsx
T
2026-05-25 01:34:52 +09:00

245 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
});