전략 목록 드래그
This commit is contained in:
@@ -45,6 +45,8 @@ import {
|
|||||||
isStartNodeId,
|
isStartNodeId,
|
||||||
type StartCombineOp,
|
type StartCombineOp,
|
||||||
} from '../utils/strategyStartNodes';
|
} from '../utils/strategyStartNodes';
|
||||||
|
import SavedStrategyDragBadge from './strategyEditor/SavedStrategyDragBadge';
|
||||||
|
import type { PaletteDragPayload } from '../utils/paletteDragSession';
|
||||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||||
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
|
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
|
||||||
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
|
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
|
||||||
@@ -1448,6 +1450,21 @@ export default function StrategyEditorPage({
|
|||||||
});
|
});
|
||||||
}, [signalTab, DEF, handleEditorStateChange]);
|
}, [signalTab, DEF, handleEditorStateChange]);
|
||||||
|
|
||||||
|
const buildSavedStrategyDragPayload = useCallback((s: StrategyDto): PaletteDragPayload | null => {
|
||||||
|
const side = buySellTab(signalTab);
|
||||||
|
const primaryRaw = side === 'sell' ? s.sellCondition : s.buyCondition;
|
||||||
|
const fallbackRaw = side === 'sell' ? s.buyCondition : s.sellCondition;
|
||||||
|
const primary = decodeConditionForEditor((primaryRaw ?? null) as LogicNode | null).root;
|
||||||
|
const condition = primary ?? decodeConditionForEditor((fallbackRaw ?? null) as LogicNode | null).root;
|
||||||
|
if (!condition) return null;
|
||||||
|
return {
|
||||||
|
type: 'savedStrategy',
|
||||||
|
value: String(s.id),
|
||||||
|
label: s.name ?? `전략 #${s.id}`,
|
||||||
|
condition,
|
||||||
|
};
|
||||||
|
}, [signalTab]);
|
||||||
|
|
||||||
const templateRows = useMemo(
|
const templateRows = useMemo(
|
||||||
() => buildStrategyTemplatePaletteRows({
|
() => buildStrategyTemplatePaletteRows({
|
||||||
def: DEF,
|
def: DEF,
|
||||||
@@ -2089,9 +2106,10 @@ export default function StrategyEditorPage({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}>
|
<SavedStrategyDragBadge
|
||||||
{s.enabled ? '활성' : '비활성'}
|
enabled={!!s.enabled}
|
||||||
</span>
|
buildPayload={() => buildSavedStrategyDragPayload(s)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
handlePaletteMouseDown,
|
||||||
|
handlePalettePointerDown,
|
||||||
|
handlePaletteTouchStart,
|
||||||
|
endHtmlDragMouseTracking,
|
||||||
|
needsPointerPaletteDrag,
|
||||||
|
startHtmlDragMouseTracking,
|
||||||
|
writePaletteHtmlDragData,
|
||||||
|
type PaletteDragPayload,
|
||||||
|
} from '../../utils/paletteDragSession';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled: boolean;
|
||||||
|
/** 드래그 시작 시점에 현재 탭(매수/매도)에 맞는 조건 payload 생성. 없으면 드래그 불가 */
|
||||||
|
buildPayload: () => PaletteDragPayload | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌측 전략 목록 아이템의 활성 배지 — 드래그하여 전략빌더(캔버스)에 조건 추가.
|
||||||
|
* 팔레트 칩과 동일한 DnD 세션(HTML5 + pointer)을 사용한다.
|
||||||
|
*/
|
||||||
|
export default function SavedStrategyDragBadge({ enabled, buildPayload }: Props) {
|
||||||
|
const pointerMode = needsPointerPaletteDrag();
|
||||||
|
|
||||||
|
const onDragStart = (e: React.DragEvent) => {
|
||||||
|
if (pointerMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
writePaletteHtmlDragData(e, payload);
|
||||||
|
startHtmlDragMouseTracking(e.clientX, e.clientY, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
endHtmlDragMouseTracking();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (!pointerMode) return;
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
handlePalettePointerDown(e, payload, e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!pointerMode) return;
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
handlePaletteMouseDown(e, payload, e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (!pointerMode) return;
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
handlePaletteTouchStart(e, payload, e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`se-strat-status se-strat-status--drag${enabled ? ' se-strat-status--on' : ''}`}
|
||||||
|
draggable={!pointerMode}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onPointerDownCapture={onPointerDown}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
title="드래그하여 전략빌더에 이 전략의 조건을 추가/연결"
|
||||||
|
>
|
||||||
|
{enabled ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import type { Theme } from '../../types/index';
|
import type { Theme } from '../../types/index';
|
||||||
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
||||||
|
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
|
||||||
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
|
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
|
||||||
import {
|
import {
|
||||||
addChild,
|
addChild,
|
||||||
@@ -609,7 +610,7 @@ function StrategyEditorCanvasInner({
|
|||||||
}, [setNodes]);
|
}, [setNodes]);
|
||||||
|
|
||||||
const resolvePaletteDropNode = useCallback((
|
const resolvePaletteDropNode = useCallback((
|
||||||
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
|
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
|
||||||
): LogicNode | null => {
|
): LogicNode | null => {
|
||||||
if (data.type === 'sidewaysFilter') {
|
if (data.type === 'sidewaysFilter') {
|
||||||
return buildSidewaysFilterNode(data.value, def);
|
return buildSidewaysFilterNode(data.value, def);
|
||||||
@@ -617,6 +618,9 @@ function StrategyEditorCanvasInner({
|
|||||||
if (data.type === 'trendLine') {
|
if (data.type === 'trendLine') {
|
||||||
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
|
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
|
||||||
}
|
}
|
||||||
|
if (data.type === 'savedStrategy') {
|
||||||
|
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
|
||||||
|
}
|
||||||
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||||
}, [signalTab, def]);
|
}, [signalTab, def]);
|
||||||
|
|
||||||
@@ -634,6 +638,20 @@ function StrategyEditorCanvasInner({
|
|||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
|
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 전략(서브트리)은 단일 고아 노드로 추가하면 자식이 캔버스에 표시되지 않으므로
|
||||||
|
* 항상 루트에 AND 병합(빈 경우 루트로 설정)한다.
|
||||||
|
*/
|
||||||
|
const addSavedStrategyAtRoot = useCallback((
|
||||||
|
data: { type: string; value: string; label: string; condition?: LogicNode },
|
||||||
|
): boolean => {
|
||||||
|
const newNode = resolvePaletteDropNode(data);
|
||||||
|
if (!newNode) return false;
|
||||||
|
onChange(root ? mergeAtRoot(root, newNode, false) : newNode);
|
||||||
|
scheduleLayoutEmit();
|
||||||
|
return true;
|
||||||
|
}, [resolvePaletteDropNode, onChange, root, scheduleLayoutEmit]);
|
||||||
|
|
||||||
const applyDropWithAttach = useCallback((
|
const applyDropWithAttach = useCallback((
|
||||||
anchorId: string,
|
anchorId: string,
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
@@ -717,17 +735,21 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
const handleDropTarget = useCallback((
|
const handleDropTarget = useCallback((
|
||||||
anchorId: string,
|
anchorId: string,
|
||||||
data: { type: string; value: string; label: string },
|
data: { type: string; value: string; label: string; condition?: LogicNode },
|
||||||
flowPos: { x: number; y: number },
|
flowPos: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
clearDropPreview();
|
clearDropPreview();
|
||||||
|
const fallback = () => {
|
||||||
|
if (data.type === 'savedStrategy') addSavedStrategyAtRoot(data);
|
||||||
|
else addOrphanAt(data, flowPos);
|
||||||
|
};
|
||||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||||
const attached = applyDropWithAttach(anchorId, data, flowPos);
|
const attached = applyDropWithAttach(anchorId, data, flowPos);
|
||||||
if (!attached) addOrphanAt(data, flowPos);
|
if (!attached) fallback();
|
||||||
} else {
|
} else {
|
||||||
addOrphanAt(data, flowPos);
|
fallback();
|
||||||
}
|
}
|
||||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, addSavedStrategyAtRoot, root, orphans]);
|
||||||
|
|
||||||
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
||||||
const next = normalizeConditionForPersistence(condition);
|
const next = normalizeConditionForPersistence(condition);
|
||||||
@@ -1295,7 +1317,7 @@ function StrategyEditorCanvasInner({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const applyPaletteDropAt = useCallback((
|
const applyPaletteDropAt = useCallback((
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean; condition?: LogicNode },
|
||||||
clientX: number,
|
clientX: number,
|
||||||
clientY: number,
|
clientY: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -1312,12 +1334,16 @@ function StrategyEditorCanvasInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addOrphanAt(data, flowPos);
|
if (data.type === 'savedStrategy') {
|
||||||
|
addSavedStrategyAtRoot(data);
|
||||||
|
} else {
|
||||||
|
addOrphanAt(data, flowPos);
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
|
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
|
||||||
});
|
});
|
||||||
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
|
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt, addSavedStrategyAtRoot]);
|
||||||
|
|
||||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { LogicNode } from '../../utils/strategyTypes';
|
import type { LogicNode } from '../../utils/strategyTypes';
|
||||||
|
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
CondEditor,
|
CondEditor,
|
||||||
addChild,
|
addChild,
|
||||||
@@ -404,7 +405,7 @@ export default function StrategyListEditor({
|
|||||||
}, [onEditorStateChange, onOrphansChange, orphans]);
|
}, [onEditorStateChange, onOrphansChange, orphans]);
|
||||||
|
|
||||||
const resolvePaletteNode = useCallback((
|
const resolvePaletteNode = useCallback((
|
||||||
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
|
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
|
||||||
) => {
|
) => {
|
||||||
if (data.type === 'sidewaysFilter') {
|
if (data.type === 'sidewaysFilter') {
|
||||||
return buildSidewaysFilterNode(data.value, def);
|
return buildSidewaysFilterNode(data.value, def);
|
||||||
@@ -412,6 +413,9 @@ export default function StrategyListEditor({
|
|||||||
if (data.type === 'trendLine') {
|
if (data.type === 'trendLine') {
|
||||||
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
|
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
|
||||||
}
|
}
|
||||||
|
if (data.type === 'savedStrategy') {
|
||||||
|
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
|
||||||
|
}
|
||||||
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||||
}, [signalTab, def]);
|
}, [signalTab, def]);
|
||||||
|
|
||||||
|
|||||||
@@ -713,6 +713,19 @@
|
|||||||
background: color-mix(in srgb, var(--se-gold) 15%, transparent);
|
background: color-mix(in srgb, var(--se-gold) 15%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
|
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
.se-strat-status--drag {
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.se-strat-status--drag:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.se-strat-status--drag:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.se-empty {
|
.se-empty {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
|||||||
@@ -358,6 +358,21 @@ export function generateNodeId(): string {
|
|||||||
return `node_${++_nodeCounter}_${Date.now()}`;
|
return `node_${++_nodeCounter}_${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 서브트리 깊은 복제 + 모든 노드 id 재생성 (저장 전략 드롭 시 id 충돌 방지) */
|
||||||
|
export function cloneLogicNodeWithNewIds(node: LogicNode): LogicNode {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
id: generateNodeId(),
|
||||||
|
condition: node.condition
|
||||||
|
? {
|
||||||
|
...node.condition,
|
||||||
|
params: node.condition.params ? { ...node.condition.params } : node.condition.params,
|
||||||
|
}
|
||||||
|
: node.condition,
|
||||||
|
children: node.children ? node.children.map(cloneLogicNodeWithNewIds) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 자연어 설명 변환 */
|
/** 자연어 설명 변환 */
|
||||||
export function nodeToNaturalLanguage(node: LogicNode): string {
|
export function nodeToNaturalLanguage(node: LogicNode): string {
|
||||||
if (!node) return '';
|
if (!node) return '';
|
||||||
|
|||||||
Reference in New Issue
Block a user