전략편집기 병합

This commit is contained in:
Macbook
2026-05-24 12:38:18 +09:00
parent 1465cb2255
commit 03fab44cb3
6 changed files with 628 additions and 81 deletions
@@ -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>
);
}