전략편집기 병합
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import {
|
||||
CondEditor,
|
||||
addChild,
|
||||
makeNode,
|
||||
mergeAtRoot,
|
||||
nodeToText,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
AND: '#4caf50',
|
||||
OR: '#2196f3',
|
||||
NOT: '#ff9800',
|
||||
};
|
||||
const IND_COLOR = '#9c27b0';
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: LogicNode;
|
||||
signalType: 'buy' | 'sell';
|
||||
onUpdate: (n: LogicNode) => void;
|
||||
onDelete: () => void;
|
||||
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string }) => void;
|
||||
dragOverId?: string | null;
|
||||
setDragOverId?: (id: string | null) => void;
|
||||
depth?: number;
|
||||
def: DefType;
|
||||
}
|
||||
|
||||
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
signalType,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDropOnNode,
|
||||
dragOverId,
|
||||
setDragOverId,
|
||||
depth = 0,
|
||||
def,
|
||||
}) => {
|
||||
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||
const label = node.type === 'CONDITION' && node.condition
|
||||
? nodeToText(node, def)
|
||||
: node.type === 'AND'
|
||||
? 'AND (그리고)'
|
||||
: node.type === 'OR'
|
||||
? 'OR (또는)'
|
||||
: 'NOT (반대)';
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!isLogic) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverId?.(node.id);
|
||||
};
|
||||
const handleDragLeave = () => setDragOverId?.(null);
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (!isLogic) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverId?.(null);
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
onDropOnNode?.(node.id, data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handleCondChange = (c: NonNullable<LogicNode['condition']>) => onUpdate({ ...node, condition: c });
|
||||
const handleChildUpdate = (childId: string, updated: LogicNode) =>
|
||||
onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) });
|
||||
const handleChildDelete = (childId: string) =>
|
||||
onUpdate({ ...node, children: (node.children ?? []).filter(c => c.id !== childId) });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sp-tree-node${dragOverId === node.id ? ' sp-tree-node--over' : ''}`}
|
||||
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="sp-node-head" style={{ borderLeftColor: color }}>
|
||||
<span className="sp-node-badge" style={{ background: color }}>
|
||||
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
||||
</span>
|
||||
<span className="sp-node-label">{label}</span>
|
||||
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
{node.type === 'CONDITION' && node.condition && (
|
||||
<CondEditor cond={node.condition} signalType={signalType} onChange={handleCondChange} def={def} />
|
||||
)}
|
||||
|
||||
{isLogic && (
|
||||
<div className="sp-node-children">
|
||||
{(node.children ?? []).map(child => (
|
||||
<TreeNodeComp
|
||||
key={child.id}
|
||||
node={child}
|
||||
signalType={signalType}
|
||||
onUpdate={u => handleChildUpdate(child.id, u)}
|
||||
onDelete={() => handleChildDelete(child.id)}
|
||||
onDropOnNode={onDropOnNode}
|
||||
dragOverId={dragOverId}
|
||||
setDragOverId={setDragOverId}
|
||||
depth={depth + 1}
|
||||
def={def}
|
||||
/>
|
||||
))}
|
||||
{dragOverId === node.id && (
|
||||
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
root: LogicNode | null;
|
||||
signalTab: 'buy' | 'sell';
|
||||
def: DefType;
|
||||
onChange: (root: LogicNode | null) => void;
|
||||
}
|
||||
|
||||
export default function StrategyListEditor({ root, signalTab, def, onChange }: Props) {
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
|
||||
const applyDrop = useCallback((
|
||||
targetId: string | null,
|
||||
data: { type: string; value: string; label: string },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
return;
|
||||
}
|
||||
if (!targetId) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
return;
|
||||
}
|
||||
onChange(addChild(root, targetId, newNode));
|
||||
}, [root, signalTab, def, onChange]);
|
||||
|
||||
const handleRootDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
try {
|
||||
applyDrop(null, JSON.parse(e.dataTransfer.getData('application/json')));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => {
|
||||
applyDrop(targetId, data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="se-list-editor sp-dropzone"
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleRootDrop}
|
||||
>
|
||||
{!root ? (
|
||||
<div className="sp-drop-empty">
|
||||
우측 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.
|
||||
</div>
|
||||
) : (
|
||||
<TreeNodeComp
|
||||
node={root}
|
||||
signalType={signalTab}
|
||||
onUpdate={onChange}
|
||||
onDelete={() => onChange(null)}
|
||||
onDropOnNode={handleDropOnNode}
|
||||
dragOverId={dragOverId}
|
||||
setDragOverId={setDragOverId}
|
||||
def={def}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user