Files
goldenChart/frontend/src/components/strategyEditor/FlowNodes.tsx
T
2026-06-15 01:04:12 +09:00

466 lines
17 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 {
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>
);
});