전략 목록 드래그
This commit is contained in:
@@ -45,6 +45,8 @@ import {
|
||||
isStartNodeId,
|
||||
type StartCombineOp,
|
||||
} from '../utils/strategyStartNodes';
|
||||
import SavedStrategyDragBadge from './strategyEditor/SavedStrategyDragBadge';
|
||||
import type { PaletteDragPayload } from '../utils/paletteDragSession';
|
||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
|
||||
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
|
||||
@@ -1448,6 +1450,21 @@ export default function StrategyEditorPage({
|
||||
});
|
||||
}, [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(
|
||||
() => buildStrategyTemplatePaletteRows({
|
||||
def: DEF,
|
||||
@@ -2089,9 +2106,10 @@ export default function StrategyEditorPage({
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}>
|
||||
{s.enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
<SavedStrategyDragBadge
|
||||
enabled={!!s.enabled}
|
||||
buildPayload={() => buildSavedStrategyDragPayload(s)}
|
||||
/>
|
||||
</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 type { Theme } from '../../types/index';
|
||||
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
||||
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
|
||||
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
|
||||
import {
|
||||
addChild,
|
||||
@@ -609,7 +610,7 @@ function StrategyEditorCanvasInner({
|
||||
}, [setNodes]);
|
||||
|
||||
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 => {
|
||||
if (data.type === 'sidewaysFilter') {
|
||||
return buildSidewaysFilterNode(data.value, def);
|
||||
@@ -617,6 +618,9 @@ function StrategyEditorCanvasInner({
|
||||
if (data.type === 'trendLine') {
|
||||
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));
|
||||
}, [signalTab, def]);
|
||||
|
||||
@@ -634,6 +638,20 @@ function StrategyEditorCanvasInner({
|
||||
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((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
@@ -717,17 +735,21 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
const handleDropTarget = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
data: { type: string; value: string; label: string; condition?: LogicNode },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
clearDropPreview();
|
||||
const fallback = () => {
|
||||
if (data.type === 'savedStrategy') addSavedStrategyAtRoot(data);
|
||||
else addOrphanAt(data, flowPos);
|
||||
};
|
||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||
const attached = applyDropWithAttach(anchorId, data, flowPos);
|
||||
if (!attached) addOrphanAt(data, flowPos);
|
||||
if (!attached) fallback();
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
fallback();
|
||||
}
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, addSavedStrategyAtRoot, root, orphans]);
|
||||
|
||||
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
||||
const next = normalizeConditionForPersistence(condition);
|
||||
@@ -1295,7 +1317,7 @@ function StrategyEditorCanvasInner({
|
||||
);
|
||||
|
||||
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,
|
||||
clientY: number,
|
||||
) => {
|
||||
@@ -1312,12 +1334,16 @@ function StrategyEditorCanvasInner({
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'savedStrategy') {
|
||||
addSavedStrategyAtRoot(data);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
|
||||
});
|
||||
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
|
||||
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt, addSavedStrategyAtRoot]);
|
||||
|
||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
|
||||
import {
|
||||
CondEditor,
|
||||
addChild,
|
||||
@@ -404,7 +405,7 @@ export default function StrategyListEditor({
|
||||
}, [onEditorStateChange, onOrphansChange, orphans]);
|
||||
|
||||
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') {
|
||||
return buildSidewaysFilterNode(data.value, def);
|
||||
@@ -412,6 +413,9 @@ export default function StrategyListEditor({
|
||||
if (data.type === 'trendLine') {
|
||||
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));
|
||||
}, [signalTab, def]);
|
||||
|
||||
|
||||
@@ -713,6 +713,19 @@
|
||||
background: color-mix(in srgb, var(--se-gold) 15%, 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 {
|
||||
font-size: 0.78rem;
|
||||
|
||||
@@ -358,6 +358,21 @@ export function generateNodeId(): string {
|
||||
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 {
|
||||
if (!node) return '';
|
||||
|
||||
Reference in New Issue
Block a user