그래프방식 조건선택 기능 추가

This commit is contained in:
Macbook
2026-06-16 10:18:30 +09:00
parent 2670c31d37
commit c7b92dbec5
4 changed files with 105 additions and 5 deletions
@@ -33,6 +33,7 @@ import {
stochPairDisplayName, stochPairDisplayName,
} from '../../utils/stochOverboughtPair'; } from '../../utils/stochOverboughtPair';
import ConditionNodeSettings from './ConditionNodeSettings'; import ConditionNodeSettings from './ConditionNodeSettings';
import ConditionPresetPicker from './ConditionPresetPicker';
import StochPairNodeSettings from './StochPairNodeSettings'; import StochPairNodeSettings from './StochPairNodeSettings';
import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings'; import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings';
import { import {
@@ -415,7 +416,9 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
const isSell = d.signalTab === 'sell'; const isSell = d.signalTab === 'sell';
const { onDragEnter, onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d); const { onDragEnter, onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [presetOpen, setPresetOpen] = useState(false);
const showSettings = cond && hasNodeSettings(cond); const showSettings = cond && hasNodeSettings(cond);
const showPreset = cond && !cond.composite;
const handleSettingsChange = useCallback((next: NonNullable<typeof cond>) => { const handleSettingsChange = useCallback((next: NonNullable<typeof cond>) => {
d.onUpdateCondition?.(id, next); d.onUpdateCondition?.(id, next);
@@ -423,7 +426,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
return ( return (
<div <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' : ''}`} 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' : ''}${presetOpen ? ' se-flow-node--preset-open' : ''}`}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
@@ -437,6 +440,25 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
<div className="se-flow-cond-top"> <div className="se-flow-cond-top">
<div className="se-flow-cond-badge">{indLabel}</div> <div className="se-flow-cond-badge">{indLabel}</div>
<div className="se-flow-cond-actions"> <div className="se-flow-cond-actions">
{showPreset && (
<button
type="button"
className="se-flow-preset"
title="조건 선택"
aria-label="조건 선택"
aria-expanded={presetOpen}
onClick={e => {
e.stopPropagation();
setPresetOpen(v => !v);
setSettingsOpen(false);
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 17l4-5 4 3 5-8 5 6" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none" />
</svg>
</button>
)}
{showSettings && ( {showSettings && (
<button <button
type="button" type="button"
@@ -446,6 +468,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
setSettingsOpen(v => !v); setSettingsOpen(v => !v);
setPresetOpen(false);
}} }}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -458,6 +481,18 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
</div> </div>
</div> </div>
<div className="se-flow-cond-text">{d.label ?? indLabel}</div> <div className="se-flow-cond-text">{d.label ?? indLabel}</div>
{presetOpen && cond && d.def && (
<ConditionPresetPicker
condition={cond}
def={d.def}
signalType={d.signalTab ?? 'buy'}
nodeId={id}
onChange={handleSettingsChange}
onReplaceNode={replaced => d.onReplaceLogicNode?.(id, replaced)}
onClose={() => setPresetOpen(false)}
popoverClassName="se-flow-preset-pop"
/>
)}
{settingsOpen && cond && d.def && ( {settingsOpen && cond && d.def && (
<ConditionNodeSettings <ConditionNodeSettings
condition={cond} condition={cond}
@@ -737,6 +737,26 @@ function StrategyEditorCanvasInner({
} }
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]); }, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
const handleReplaceLogicNode = useCallback((id: string, replaced: LogicNode) => {
if (isOrphanNode(orphans, id)) {
onOrphansChange(orphans.map(o => (o.id === id ? replaced : o)));
return;
}
if (root && findNodeInTree(root, id)) {
onChange(updateNode(root, id, () => replaced));
return;
}
for (const [sid, branch] of Object.entries(extraRoots)) {
if (branch && findNodeInTree(branch, id)) {
onExtraRootsChange?.({
...extraRoots,
[sid]: updateNode(branch, id, () => replaced),
});
return;
}
}
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => { const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
const patchGate = (n: LogicNode) => ( const patchGate = (n: LogicNode) => (
n.type === 'AND' || n.type === 'OR' n.type === 'AND' || n.type === 'OR'
@@ -868,6 +888,7 @@ function StrategyEditorCanvasInner({
const flowCallbacks = useMemo(() => ({ const flowCallbacks = useMemo(() => ({
onDelete: handleDelete, onDelete: handleDelete,
onUpdateCondition: handleUpdateCondition, onUpdateCondition: handleUpdateCondition,
onReplaceLogicNode: handleReplaceLogicNode,
onUpdateStochPairSecondary: handleUpdateStochPairSecondary, onUpdateStochPairSecondary: handleUpdateStochPairSecondary,
onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel, onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel,
onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel, onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel,
@@ -879,7 +900,7 @@ function StrategyEditorCanvasInner({
onStartCandleTypesChange: handleStartCandleTypesChange, onStartCandleTypesChange: handleStartCandleTypesChange,
onDeleteStart: handleDeleteStart, onDeleteStart: handleDeleteStart,
}), [ }), [
handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType,
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
handleStartCandleTypesChange, handleDeleteStart, handleStartCandleTypesChange, handleDeleteStart,
]); ]);
+40 -1
View File
@@ -1218,18 +1218,57 @@
transition: opacity 0.12s, background 0.12s; transition: opacity 0.12s, background 0.12s;
flex-shrink: 0; flex-shrink: 0;
} }
.se-flow-preset {
border: none;
background: rgba(167, 139, 250, 0.2);
color: #a78bfa;
width: 18px; height: 18px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
flex-shrink: 0;
}
.se-flow-preset:hover {
background: rgba(167, 139, 250, 0.35);
}
.se-flow-settings:hover { .se-flow-settings:hover {
background: rgba(230, 194, 0, 0.2); background: rgba(230, 194, 0, 0.2);
} }
.se-flow-node:hover .se-flow-del, .se-flow-node:hover .se-flow-del,
.se-flow-node:hover .se-flow-settings, .se-flow-node:hover .se-flow-settings,
.se-flow-node:hover .se-flow-preset,
.se-flow-node--selected .se-flow-del, .se-flow-node--selected .se-flow-del,
.se-flow-node--selected .se-flow-settings, .se-flow-node--selected .se-flow-settings,
.se-flow-node--selected .se-flow-preset,
.se-flow-node--settings-open .se-flow-del, .se-flow-node--settings-open .se-flow-del,
.se-flow-node--settings-open .se-flow-settings { .se-flow-node--settings-open .se-flow-settings,
.se-flow-node--settings-open .se-flow-preset,
.se-flow-node--preset-open .se-flow-del,
.se-flow-node--preset-open .se-flow-settings,
.se-flow-node--preset-open .se-flow-preset {
opacity: 1; opacity: 1;
} }
.se-flow-preset-pop {
position: absolute;
top: 6px;
right: 6px;
z-index: 40;
width: min(320px, calc(100vw - 24px));
max-height: min(420px, 60vh);
overflow: auto;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, #a78bfa 45%, transparent);
background: var(--se-bg-elevated);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.se-flow-settings-pop { .se-flow-settings-pop {
position: absolute; position: absolute;
top: 6px; top: 6px;
+7 -2
View File
@@ -40,6 +40,8 @@ export type StrategyFlowNodeData = {
onDeleteStart?: (startId: string) => void; onDeleteStart?: (startId: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void; onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
/** 조건 프리셋(복합) — 노드 전체 교체 (CONDITION → AND 등) */
onReplaceLogicNode?: (nodeId: string, node: LogicNode) => void;
/** Stoch 과열×보조 복합 — 보조지표 변경 */ /** Stoch 과열×보조 복합 — 보조지표 변경 */
onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void; onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void;
/** 9·20 신고가/신저가 복합 — 필터 강도 변경 */ /** 9·20 신고가/신저가 복합 — 필터 강도 변경 */
@@ -480,7 +482,7 @@ function layoutTree(
signalTab: 'buy' | 'sell', signalTab: 'buy' | 'sell',
callbacks: Pick< callbacks: Pick<
StrategyFlowNodeData, StrategyFlowNodeData,
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' 'onDelete' | 'onUpdateCondition' | 'onReplaceLogicNode' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType'
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
>, >,
dragOverId: string | null, dragOverId: string | null,
@@ -502,6 +504,7 @@ function layoutTree(
signalTab, signalTab,
onDelete: callbacks.onDelete, onDelete: callbacks.onDelete,
onUpdateCondition: callbacks.onUpdateCondition, onUpdateCondition: callbacks.onUpdateCondition,
onReplaceLogicNode: callbacks.onReplaceLogicNode,
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel,
onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel,
@@ -635,7 +638,7 @@ function appendOrphanFlowNodes(
signalTab: 'buy' | 'sell', signalTab: 'buy' | 'sell',
callbacks: Pick< callbacks: Pick<
StrategyFlowNodeData, StrategyFlowNodeData,
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' 'onDelete' | 'onUpdateCondition' | 'onReplaceLogicNode' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType'
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
>, >,
): void { ): void {
@@ -657,6 +660,7 @@ function appendOrphanFlowNodes(
signalTab, signalTab,
onDelete: callbacks.onDelete, onDelete: callbacks.onDelete,
onUpdateCondition: callbacks.onUpdateCondition, onUpdateCondition: callbacks.onUpdateCondition,
onReplaceLogicNode: callbacks.onReplaceLogicNode,
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel,
onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel,
@@ -684,6 +688,7 @@ export function logicNodeToFlow(
StrategyFlowNodeData, StrategyFlowNodeData,
| 'onDelete' | 'onDelete'
| 'onUpdateCondition' | 'onUpdateCondition'
| 'onReplaceLogicNode'
| 'onUpdateStochPairSecondary' | 'onUpdateStochPairSecondary'
| 'onUpdatePriceExtremeFilterLevel' | 'onUpdatePriceExtremeFilterLevel'
| 'onUpdateInflection33FilterLevel' | 'onUpdateInflection33FilterLevel'