466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
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 {
|
||
isPriceExtremeBreakoutPairRoot,
|
||
priceExtremePairDisplayName,
|
||
inferPriceExtremeFilterLevel,
|
||
} from '../../utils/priceExtremeBreakoutPair';
|
||
import {
|
||
isInflection33PairRoot,
|
||
inflection33DisplayName,
|
||
inferInflection33FilterLevel,
|
||
} from '../../utils/inflection33Strategy';
|
||
import Inflection33PairNodeSettings from './Inflection33PairNodeSettings';
|
||
import {
|
||
isStableStrategyPairRoot,
|
||
stableStrategyDisplayName,
|
||
inferStableStrategyFilterLevel,
|
||
type StableStrategyId,
|
||
} from '../../utils/stableStrategyPairs';
|
||
import StableStrategyPairNodeSettings from './StableStrategyPairNodeSettings';
|
||
import {
|
||
isStochOverboughtPairRoot,
|
||
stochPairDisplayName,
|
||
} from '../../utils/stochOverboughtPair';
|
||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||
import StochPairNodeSettings from './StochPairNodeSettings';
|
||
import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings';
|
||
import {
|
||
isPaletteHtmlDrag,
|
||
readPaletteHtmlDragData,
|
||
} from '../../utils/paletteDragSession';
|
||
|
||
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 onDragEnter = useCallback((e: React.DragEvent) => {
|
||
if (!isPaletteHtmlDrag(e)) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, []);
|
||
|
||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||
if (!isPaletteHtmlDrag(e)) 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();
|
||
const payload = readPaletteHtmlDragData(e);
|
||
if (!payload) return;
|
||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||
d.onDropTarget?.(id, payload, flowPos);
|
||
}, [d, id, screenToFlowPosition]);
|
||
|
||
return { onDragEnter, onDragOver, onDragLeave, onDrop };
|
||
}
|
||
|
||
export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||
const d = data as StrategyFlowNodeData;
|
||
const isSell = d.signalTab === 'sell';
|
||
const { onDragEnter, 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' : ''}`}
|
||
onDragEnter={onDragEnter}
|
||
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 isStochPair = isStochOverboughtPairRoot(node);
|
||
const isPriceExtremePair = isPriceExtremeBreakoutPairRoot(node);
|
||
const isInflection33Pair = isInflection33PairRoot(node);
|
||
const isStableStrategyPair = isStableStrategyPairRoot(node);
|
||
const { onDragEnter, onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
|
||
return (
|
||
<div
|
||
className={`se-flow-node se-flow-node--logic${isStochPair ? ' se-flow-node--stoch-pair' : ''}${isPriceExtremePair ? ' se-flow-node--price-extreme-pair' : ''}${isInflection33Pair ? ' se-flow-node--inflection33-pair' : ''}${isStableStrategyPair ? ' se-flow-node--stable-strategy-pair' : ''}${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' : ''}`}
|
||
style={{ '--se-gate-color': color } as React.CSSProperties}
|
||
onDragEnter={onDragEnter}
|
||
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>
|
||
{isStableStrategyPair && node.stableStrategyPair && (
|
||
<div className="se-flow-gate-stoch-pair">
|
||
<span className="se-flow-gate-stoch-pair-title">
|
||
{stableStrategyDisplayName(
|
||
node.stableStrategyPair.strategyId as StableStrategyId,
|
||
node.stableStrategyPair.mode,
|
||
)}
|
||
</span>
|
||
<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>
|
||
</div>
|
||
)}
|
||
{isInflection33Pair && node.inflection33Pair && (
|
||
<div className="se-flow-gate-stoch-pair">
|
||
<span className="se-flow-gate-stoch-pair-title">
|
||
{inflection33DisplayName(node.inflection33Pair.mode, node.inflection33Pair.period)}
|
||
</span>
|
||
<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>
|
||
</div>
|
||
)}
|
||
{isPriceExtremePair && node.priceExtremePair && (
|
||
<div className="se-flow-gate-stoch-pair">
|
||
<span className="se-flow-gate-stoch-pair-title">
|
||
{priceExtremePairDisplayName(
|
||
node.priceExtremePair.mode,
|
||
node.priceExtremePair.shortPeriod,
|
||
node.priceExtremePair.longPeriod,
|
||
)}
|
||
</span>
|
||
<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>
|
||
</div>
|
||
)}
|
||
{isStochPair && node.stochPair && (
|
||
<div className="se-flow-gate-stoch-pair">
|
||
<span className="se-flow-gate-stoch-pair-title">
|
||
{stochPairDisplayName(node.stochPair.secondaryIndicatorType)}
|
||
</span>
|
||
<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>
|
||
</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>
|
||
{settingsOpen && isStochPair && node.stochPair && (
|
||
<StochPairNodeSettings
|
||
secondaryIndicator={node.stochPair.secondaryIndicatorType}
|
||
onChange={sec => d.onUpdateStochPairSecondary?.(id, sec)}
|
||
onClose={() => setSettingsOpen(false)}
|
||
/>
|
||
)}
|
||
{settingsOpen && isStableStrategyPair && node.stableStrategyPair && (
|
||
<StableStrategyPairNodeSettings
|
||
strategyId={node.stableStrategyPair.strategyId as StableStrategyId}
|
||
mode={node.stableStrategyPair.mode}
|
||
filterLevel={inferStableStrategyFilterLevel(node)}
|
||
def={d.def!}
|
||
onChange={level => d.onUpdateStableStrategyFilterLevel?.(id, level)}
|
||
onClose={() => setSettingsOpen(false)}
|
||
/>
|
||
)}
|
||
{settingsOpen && isInflection33Pair && node.inflection33Pair && (
|
||
<Inflection33PairNodeSettings
|
||
mode={node.inflection33Pair.mode}
|
||
period={node.inflection33Pair.period}
|
||
filterLevel={inferInflection33FilterLevel(node)}
|
||
onChange={level => d.onUpdateInflection33FilterLevel?.(id, level)}
|
||
onClose={() => setSettingsOpen(false)}
|
||
/>
|
||
)}
|
||
{settingsOpen && isPriceExtremePair && node.priceExtremePair && (
|
||
<PriceExtremePairNodeSettings
|
||
mode={node.priceExtremePair.mode}
|
||
shortPeriod={node.priceExtremePair.shortPeriod}
|
||
longPeriod={node.priceExtremePair.longPeriod}
|
||
filterLevel={inferPriceExtremeFilterLevel(node)}
|
||
onChange={level => d.onUpdatePriceExtremeFilterLevel?.(id, level)}
|
||
onClose={() => setSettingsOpen(false)}
|
||
/>
|
||
)}
|
||
</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 { onDragEnter, 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' : ''}`}
|
||
onDragEnter={onDragEnter}
|
||
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>
|
||
);
|
||
});
|