Files
goldenChart/frontend/src/components/strategyEditor/FlowNodes.tsx
T

294 lines
10 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 {
isStartNodeId,
type HandleSide,
type StrategyFlowNodeData,
} from '../../utils/strategyFlowLayout';
import { getStartCandleTypes } from '../../utils/strategyStartNodes';
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
import LogicGateOpToggle from './LogicGateOpToggle';
import { hasNodeSettings } from '../../utils/conditionPeriods';
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
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({ id, data }: NodeProps) {
const d = data as StrategyFlowNodeData;
const isSell = d.signalTab === 'sell';
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
const candleTypes = d.candleTypes?.length
? d.candleTypes
: getStartCandleTypes({ candleType: d.candleType ?? '1m' });
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
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={id}
sourceOnly
activeSourceSide={d.activeSourceSide}
canSourceConnect={d.canSourceConnect !== false}
/>
<div className="se-flow-start-layout">
<div className="se-flow-start-title-row">
<div className="se-flow-start-title-left">
<div className="se-flow-start-dot" />
<span className="se-flow-start-label">START</span>
</div>
{canDelete && (
<button
type="button"
className="se-flow-del se-flow-del--start"
title="START 삭제"
onClick={e => {
e.stopPropagation();
d.onDeleteStart?.(id);
}}
>
×
</button>
)}
</div>
<StartTimeframeCheckboxes
layout="graph"
className="se-flow-start-tf-checks-wrap"
selected={candleTypes}
onChange={types => d.onStartCandleTypesChange?.(id, types)}
/>
</div>
</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">
{node.type === 'AND' || node.type === 'OR' ? (
<LogicGateOpToggle
value={node.type}
onChange={op => d.onChangeLogicGateType?.(id, op)}
compact
/>
) : (
<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 indLabel = getStrategyIndicatorDisplayName(ind);
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">{indLabel}</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 ?? indLabel}</div>
{settingsOpen && cond && d.def && (
<ConditionNodeSettings
condition={cond}
def={d.def}
signalType={d.signalTab ?? 'buy'}
onChange={handleSettingsChange}
onClose={() => setSettingsOpen(false)}
/>
)}
</div>
);
});