다중 시간봉 전략 설정기능 추가

This commit is contained in:
Macbook
2026-05-25 02:49:39 +09:00
parent cc01f311bc
commit 30dedc4abc
17 changed files with 1903 additions and 249 deletions
@@ -1,6 +1,11 @@
import React, { memo, useCallback, useState } from 'react';
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
import {
isStartNodeId,
STRATEGY_CANDLE_TYPES,
type HandleSide,
type StrategyFlowNodeData,
} from '../../utils/strategyFlowLayout';
import { hasNodeSettings } from '../../utils/conditionPeriods';
import ConditionNodeSettings from './ConditionNodeSettings';
@@ -122,10 +127,14 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
return { onDragOver, onDragLeave, onDrop };
}
export const StartNode = memo(function StartNode({ data }: NodeProps) {
export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
const d = data as StrategyFlowNodeData;
const isSell = d.signalTab === 'sell';
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(START_NODE_ID, d);
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
const candleType = d.candleType ?? '1m';
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
return (
<div
@@ -135,13 +144,41 @@ export const StartNode = memo(function StartNode({ data }: NodeProps) {
onDrop={onDrop}
>
<MultiSideHandles
nodeId={START_NODE_ID}
nodeId={id}
sourceOnly
activeSourceSide={d.activeSourceSide}
canSourceConnect={d.canSourceConnect !== false}
/>
<div className="se-flow-start-dot" />
<span>START</span>
<div className="se-flow-start-head">
<div className="se-flow-start-dot" />
<span className="se-flow-start-label">START</span>
<select
className="se-flow-start-tf"
value={candleType}
title="조건 판별 시간봉"
onPointerDown={stop}
onMouseDown={stop}
onClick={stop}
onChange={e => d.onStartCandleTypeChange?.(id, e.target.value)}
>
{STRATEGY_CANDLE_TYPES.map(ct => (
<option key={ct} value={ct}>{ct}</option>
))}
</select>
{canDelete && (
<button
type="button"
className="se-flow-del se-flow-del--start"
title="START 삭제"
onClick={e => {
e.stopPropagation();
d.onDeleteStart?.(id);
}}
>
×
</button>
)}
</div>
</div>
);
});
@@ -2,10 +2,18 @@ import React from 'react';
import type { LogicNode } from '../../utils/strategyTypes';
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
import { getIndicatorPaletteCategory } from './paletteCategories';
import {
collectEditorBranches,
normalizeStartCombineOp,
type EditorConditionState,
} from '../../utils/strategyConditionSerde';
import { formatStartCandleLabel } from '../../utils/strategyStartNodes';
interface Props {
buyCondition: LogicNode | null;
sellCondition: LogicNode | null;
buyEditorState?: EditorConditionState;
sellEditorState?: EditorConditionState;
orphanCount: number;
def: DefType;
}
@@ -81,34 +89,106 @@ function renderNode(node: LogicNode, def: DefType): React.ReactNode {
);
}
if (node.type === 'TIMEFRAME') {
const inner = node.children?.[0];
const tf = formatStartCandleLabel(node.candleType ?? '1m');
return inner ? (
<>
<span className="se-formula-tf">[{tf}]</span>
{' '}
{renderNode(inner, def)}
</>
) : (
<span className="se-formula-empty">[{tf} ]</span>
);
}
return null;
}
function renderSignalBranches(
editorState: EditorConditionState | undefined,
fallbackRoot: LogicNode | null,
def: DefType,
): React.ReactNode {
const branches = editorState
? collectEditorBranches(editorState)
: fallbackRoot
? [{ candleType: '1m', root: fallbackRoot }]
: [];
if (branches.length === 0) {
return <span className="se-formula-empty">( )</span>;
}
if (branches.length === 1) {
const { candleType, root } = branches[0];
if (!root) return <span className="se-formula-empty">( )</span>;
return (
<>
<span className="se-formula-tf">[{formatStartCandleLabel(candleType)}]</span>
{' '}
{renderNode(root, def)}
</>
);
}
const combineOp = normalizeStartCombineOp(editorState?.startCombineOp);
const combineClass = combineOp === 'AND' ? 'se-formula-logic--and' : 'se-formula-logic--or';
return (
<>
<span className="se-formula-punc">(</span>
{branches.map((branch, i) => (
<React.Fragment key={`${branch.candleType}-${i}`}>
{i > 0 ? (
<span className={`se-formula-logic ${combineClass}`}> {combineOp} </span>
) : null}
{branch.root ? (
<>
<span className="se-formula-tf">[{formatStartCandleLabel(branch.candleType)}]</span>
{' '}
{renderNode(branch.root, def)}
</>
) : (
<span className="se-formula-empty">[{formatStartCandleLabel(branch.candleType)} ]</span>
)}
</React.Fragment>
))}
<span className="se-formula-punc">)</span>
</>
);
}
export default function LogicExpressionPreview({
buyCondition,
sellCondition,
buyEditorState,
sellEditorState,
orphanCount,
def,
}: Props) {
const hasBody = buyCondition || sellCondition;
const hasBody = buyCondition || sellCondition
|| (buyEditorState && collectEditorBranches(buyEditorState).some(b => b.root))
|| (sellEditorState && collectEditorBranches(sellEditorState).some(b => b.root));
return (
<div className="se-terminal-text se-terminal-text--rich">
{!hasBody && (
<span className="se-formula-empty">( )</span>
)}
{buyCondition && (
{(buyCondition || buyEditorState) && (
<div className="se-formula-line">
<span className="se-formula-signal se-formula-signal--buy">[]</span>
{' '}
{renderNode(buyCondition, def)}
{renderSignalBranches(buyEditorState, buyCondition, def)}
</div>
)}
{sellCondition && (
{(sellCondition || sellEditorState) && (
<div className="se-formula-line">
<span className="se-formula-signal se-formula-signal--sell">[]</span>
{' '}
{renderNode(sellCondition, def)}
{renderSignalBranches(sellEditorState, sellCondition, def)}
</div>
)}
{orphanCount > 0 && (
@@ -1,14 +1,14 @@
import React from 'react';
export interface PaletteDragPayload {
type: 'operator' | 'indicator';
type: 'operator' | 'indicator' | 'start';
value: string;
label: string;
composite?: boolean;
}
interface Props {
type: 'operator' | 'indicator';
type: 'operator' | 'indicator' | 'start';
value: string;
label: string;
desc?: string;
@@ -0,0 +1,33 @@
import React from 'react';
import type { StartCombineOp } from '../../utils/strategyStartNodes';
interface Props {
value: StartCombineOp;
onChange: (op: StartCombineOp) => void;
}
export default function StartCombineOpControl({ value, onChange }: Props) {
return (
<div className="se-start-combine" role="group" aria-label="START 간 연산">
<span className="se-start-combine-label">START </span>
<div className="se-start-combine-toggle">
<button
type="button"
className={`se-start-combine-btn se-start-combine-btn--and${value === 'AND' ? ' se-start-combine-btn--on' : ''}`}
aria-pressed={value === 'AND'}
onClick={() => onChange('AND')}
>
AND
</button>
<button
type="button"
className={`se-start-combine-btn se-start-combine-btn--or${value === 'OR' ? ' se-start-combine-btn--on' : ''}`}
aria-pressed={value === 'OR'}
onClick={() => onChange('OR')}
>
OR
</button>
</div>
</div>
);
}
@@ -43,13 +43,23 @@ import {
isDescendant,
isOrphanNode,
isPaletteConnectTarget,
isStartNodeId,
isValidStrategyConnection,
resolveStrategyConnection,
resolveOrphanDragConnection,
logicNodeToFlow,
computeAutoFlowLayout,
spawnPositionNearAnchor,
subtreeToFlatOrphans,
type EdgeHandleBinding,
} from '../../utils/strategyFlowLayout';
import {
createStartNodeId,
defaultStartMeta,
DEFAULT_START_CANDLE,
normalizeStartCandleType,
type StartNodeMeta,
} from '../../utils/strategyStartNodes';
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
import { StrategyFlowEdge } from './StrategyFlowEdge';
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
@@ -90,6 +100,12 @@ interface Props {
onSelectNode: (id: string | null) => void;
layoutSeed: FlowLayoutSeed;
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
startMeta?: Record<string, StartNodeMeta>;
extraStartIds?: string[];
extraRoots?: Record<string, LogicNode | null>;
onStartMetaChange?: (meta: Record<string, StartNodeMeta>) => void;
onExtraStartIdsChange?: (ids: string[]) => void;
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
}
function collectTreeIds(node: LogicNode | null): string[] {
@@ -117,28 +133,76 @@ function saveEdgeHandles(
function applyTreeConnection(
sourceId: string,
targetId: string,
root: LogicNode,
root: LogicNode | null,
extraRoots: Record<string, LogicNode | null>,
onChange: (root: LogicNode | null) => void,
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
) {
if (sourceId === START_NODE_ID) {
const { tree, node } = extractNode(root, targetId);
if (!node) return;
onChange(node);
if (tree && tree.children?.length) {
onChange(mergeAtRoot(node, tree, false));
const promoteToStart = (startId: string, node: LogicNode, remainder: LogicNode | null) => {
if (startId === START_NODE_ID) {
onChange(remainder ? mergeAtRoot(node, remainder, false) : node);
return;
}
const branch = extraRoots[startId] ?? null;
onExtraRootsChange({
...extraRoots,
[startId]: branch ? mergeAtRoot(branch, node, false) : node,
});
};
if (isStartNodeId(sourceId)) {
if (root && (root.id === targetId || findNodeInTree(root, targetId))) {
const { tree, node } = extractNode(root, targetId);
if (!node) return;
onChange(tree);
promoteToStart(sourceId, node, tree);
return;
}
for (const [sid, branch] of Object.entries(extraRoots)) {
if (!branch || !findNodeInTree(branch, targetId)) continue;
const { tree, node } = extractNode(branch, targetId);
if (!node) return;
onExtraRootsChange({ ...extraRoots, [sid]: tree });
promoteToStart(sourceId, node, null);
return;
}
return;
}
const sourceNode = findNodeInTree(root, sourceId);
if (!root) return;
const sourceNode = findNodeInTree(root, sourceId)
?? Object.values(extraRoots).map(br => findNodeInTree(br, sourceId)).find(Boolean);
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
if (isDescendant(root, targetId, sourceId)) return;
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
if (!moved) return;
const base = afterRemove ?? root;
if (sourceId === targetId) return;
onChange(addChild(base, sourceId, moved));
if (findNodeInTree(root, targetId)) {
if (isDescendant(root, targetId, sourceId)) return;
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
if (!moved) return;
onChange(addChild(afterRemove ?? root, sourceId, moved));
return;
}
for (const [sid, branch] of Object.entries(extraRoots)) {
if (!branch || !findNodeInTree(branch, targetId)) continue;
if (isDescendant(branch, targetId, sourceId)) return;
const { tree, node: moved } = extractNode(branch, targetId);
if (!moved) return;
onExtraRootsChange({ ...extraRoots, [sid]: tree });
if (findNodeInTree(root, sourceId)) {
onChange(addChild(root, sourceId, moved));
} else {
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, sourceId));
if (parentBranch) {
const [psid, pbr] = parentBranch;
onExtraRootsChange({
...extraRoots,
[sid]: tree,
[psid]: addChild(pbr!, sourceId, moved),
});
}
}
return;
}
}
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
@@ -152,11 +216,21 @@ function connectOrphanIntoTree(
conn: Connection,
orphan: LogicNode,
root: LogicNode | null,
extraRoots: Record<string, LogicNode | null>,
onChange: (root: LogicNode | null) => void,
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
): void {
const sourceId = conn.source!;
if (sourceId === START_NODE_ID) {
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
if (isStartNodeId(sourceId)) {
if (sourceId === START_NODE_ID) {
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
return;
}
const branch = extraRoots[sourceId] ?? null;
onExtraRootsChange({
...extraRoots,
[sourceId]: branch ? mergeAtRoot(branch, orphan, false) : orphan,
});
return;
}
if (!root) return;
@@ -189,6 +263,12 @@ function StrategyEditorCanvasInner({
onSelectNode,
layoutSeed,
onLayoutChange,
startMeta = defaultStartMeta(),
extraStartIds = [],
extraRoots = {},
onStartMetaChange,
onExtraStartIdsChange,
onExtraRootsChange,
}: Props) {
const { fitView, screenToFlowPosition } = useReactFlow();
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
@@ -212,29 +292,34 @@ function StrategyEditorCanvasInner({
nodesRef.current = nodes;
edgesRef.current = edges;
const treeStructureKey = useMemo(
() => collectTreeIds(root).join('|'),
[root],
);
const treeStructureKey = useMemo(() => {
const parts = [collectTreeIds(root).join('|')];
for (const sid of extraStartIds) parts.push(sid);
for (const br of Object.values(extraRoots)) parts.push(collectTreeIds(br).join('|'));
return parts.join('::');
}, [root, extraStartIds, extraRoots]);
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
const treeLinkedIds = useMemo(() => {
const ids = new Set<string>([START_NODE_ID]);
const ids = new Set<string>([START_NODE_ID, ...extraStartIds]);
for (const id of collectTreeIds(root)) ids.add(id);
for (const br of Object.values(extraRoots)) {
for (const id of collectTreeIds(br)) ids.add(id);
}
return ids;
}, [root]);
}, [root, extraStartIds, extraRoots]);
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
const minimapNodeColor = useCallback((node: Node) => {
if (node.id === START_NODE_ID) return minimapColors.start;
if (isStartNodeId(node.id)) return minimapColors.start;
if (node.type === 'condition') return minimapColors.condition;
return minimapColors.logic;
}, [minimapColors]);
const minimapNodeStrokeColor = useCallback((node: Node) => {
if (node.id === START_NODE_ID) return minimapColors.start;
if (isStartNodeId(node.id)) return minimapColors.start;
return minimapColors.stroke;
}, [minimapColors]);
@@ -285,31 +370,90 @@ function StrategyEditorCanvasInner({
};
}, [emitLayoutChange]);
const handleStartCandleTypeChange = useCallback((startId: string, candleType: string) => {
onStartMetaChange?.({
...startMeta,
[startId]: { candleType: normalizeStartCandleType(candleType) },
});
}, [startMeta, onStartMetaChange]);
const handleDeleteStart = useCallback((startId: string) => {
if (startId === START_NODE_ID) return;
const branch = extraRoots[startId];
if (branch) {
onOrphansChange([...orphans, ...subtreeToFlatOrphans(branch)]);
}
onExtraStartIdsChange?.(extraStartIds.filter(id => id !== startId));
const nextMeta = { ...startMeta };
delete nextMeta[startId];
onStartMetaChange?.(nextMeta);
const nextRoots = { ...extraRoots };
delete nextRoots[startId];
onExtraRootsChange?.(nextRoots);
positionsRef.current.delete(startId);
scheduleLayoutEmit();
}, [
extraRoots, extraStartIds, startMeta, orphans,
onOrphansChange, onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit,
]);
const addStartAt = useCallback((flowPos: { x: number; y: number }) => {
const newId = createStartNodeId();
onExtraStartIdsChange?.([...extraStartIds, newId]);
onStartMetaChange?.({
...startMeta,
[newId]: { candleType: DEFAULT_START_CANDLE },
});
onExtraRootsChange?.({ ...extraRoots, [newId]: null });
positionsRef.current.set(newId, {
x: flowPos.x - getNodeDimensions(newId).w / 2,
y: flowPos.y - getNodeDimensions(newId).h / 2,
});
scheduleLayoutEmit();
}, [
extraStartIds, extraRoots, startMeta,
onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit,
]);
const handleDelete = useCallback((id: string) => {
if (id === START_NODE_ID) return;
if (isStartNodeId(id)) return;
if (isOrphanNode(orphans, id)) {
onOrphansChange(orphans.filter(o => o.id !== id));
positionsRef.current.delete(id);
if (selectedNodeId === id) onSelectNode(null);
return;
}
if (!root) return;
if (root.id === id) {
onChange(null);
onSelectNode(null);
if (root && (root.id === id || findNodeInTree(root, id))) {
if (root.id === id) {
onChange(null);
} else {
onChange(deleteNode(root, id));
}
positionsRef.current.delete(id);
if (selectedNodeId === id) onSelectNode(null);
scheduleLayoutEmit();
return;
}
const next = deleteNode(root, id);
onChange(next);
positionsRef.current.delete(id);
if (selectedNodeId === id) onSelectNode(null);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId, scheduleLayoutEmit]);
for (const [sid, branch] of Object.entries(extraRoots)) {
if (!branch || !findNodeInTree(branch, id)) continue;
if (branch.id === id) {
onExtraRootsChange?.({ ...extraRoots, [sid]: null });
} else {
onExtraRootsChange?.({ ...extraRoots, [sid]: deleteNode(branch, id) });
}
positionsRef.current.delete(id);
if (selectedNodeId === id) onSelectNode(null);
scheduleLayoutEmit();
return;
}
}, [
root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange,
onSelectNode, selectedNodeId, scheduleLayoutEmit,
]);
const deleteSelectedNodes = useCallback(() => {
const ids = nodesRef.current
.filter(n => n.selected && n.id !== START_NODE_ID)
.filter(n => n.selected && !isStartNodeId(n.id))
.map(n => n.id);
if (!ids.length) return;
@@ -426,6 +570,11 @@ function StrategyEditorCanvasInner({
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
if (data.type === 'start') {
addStartAt(flowPos);
return;
}
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
@@ -438,20 +587,23 @@ function StrategyEditorCanvasInner({
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
};
if (!root) {
onChange(newNode);
saveAttachHandles(START_NODE_ID);
scheduleLayoutEmit();
return;
}
if (anchorId === START_NODE_ID) {
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
saveAttachHandles(START_NODE_ID);
if (isStartNodeId(anchorId)) {
if (anchorId === START_NODE_ID) {
onChange(root ? mergeAtRoot(root, newNode, data.type === 'operator') : newNode);
} else {
const branch = extraRoots[anchorId] ?? null;
onExtraRootsChange?.({
...extraRoots,
[anchorId]: branch ? mergeAtRoot(branch, newNode, data.type === 'operator') : newNode,
});
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
return;
}
const anchorNode = findNodeInTree(root, anchorId);
const anchorNode = findNodeInTree(root, anchorId)
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
if (anchorNode?.type === 'CONDITION') {
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
return;
@@ -459,10 +611,21 @@ function StrategyEditorCanvasInner({
if (!canAcceptPaletteAsParent(anchorId, root)) return;
onChange(addChild(root, anchorId, newNode));
if (root && findNodeInTree(root, anchorId)) {
onChange(addChild(root, anchorId, newNode));
} else {
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId));
if (parentBranch) {
const [sid, br] = parentBranch;
onExtraRootsChange?.({
...extraRoots,
[sid]: addChild(br!, anchorId, newNode),
});
}
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
}, [root, signalTab, def, onChange, scheduleLayoutEmit]);
}, [root, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
updateDropPreview(anchorId, flowPos);
@@ -496,9 +659,20 @@ function StrategyEditorCanvasInner({
)));
return;
}
if (!root) return;
onChange(updateNode(root, id, n => ({ ...n, condition })));
}, [root, orphans, onChange, onOrphansChange]);
if (root && findNodeInTree(root, id)) {
onChange(updateNode(root, id, n => ({ ...n, condition })));
return;
}
for (const [sid, branch] of Object.entries(extraRoots)) {
if (branch && findNodeInTree(branch, id)) {
onExtraRootsChange?.({
...extraRoots,
[sid]: updateNode(branch, id, n => ({ ...n, condition })),
});
return;
}
}
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
const flowCallbacks = useMemo(() => ({
onDelete: handleDelete,
@@ -506,7 +680,13 @@ function StrategyEditorCanvasInner({
onDropTarget: handleDropTarget,
onDragOverTarget: handleDragOverTarget,
onDragLeaveTarget: handleDragLeaveTarget,
}), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
onStartCandleTypeChange: handleStartCandleTypeChange,
onDeleteStart: handleDeleteStart,
}), [
handleDelete, handleUpdateCondition, handleDropTarget,
handleDragOverTarget, handleDragLeaveTarget,
handleStartCandleTypeChange, handleDeleteStart,
]);
const rebuildFlow = useCallback(() => logicNodeToFlow(
root,
@@ -516,7 +696,10 @@ function StrategyEditorCanvasInner({
flowCallbacks,
null,
orphans,
), [root, def, signalTab, flowCallbacks, orphans]);
startMeta,
extraStartIds,
extraRoots,
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
useEffect(() => {
@@ -573,20 +756,22 @@ function StrategyEditorCanvasInner({
if (!edge?.source || !edge.target) return;
edgeHandlesRef.current.delete(edgeId);
const { root: nextRoot, orphans: nextOrphans } = disconnectTreeEdge(
const { root: nextRoot, orphans: nextOrphans, extraRoots: nextExtraRoots } = disconnectTreeEdge(
edge.source,
edge.target,
root,
orphans,
extraRoots,
);
if (nextRoot === root && nextOrphans.length === orphans.length) return;
if (nextRoot === root && nextOrphans.length === orphans.length && nextExtraRoots === extraRoots) return;
onChange(nextRoot);
onOrphansChange(nextOrphans);
onExtraRootsChange?.(nextExtraRoots);
onSelectNode(null);
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
}, [root, orphans, onChange, onOrphansChange, onSelectNode, setEdges]);
}, [root, orphans, extraRoots, onChange, onOrphansChange, onExtraRootsChange, onSelectNode, setEdges]);
useEffect(() => {
edgeDisconnectRef.current = handleDisconnectEdge;
@@ -607,8 +792,31 @@ function StrategyEditorCanvasInner({
};
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
const handleAutoLayout = useCallback(() => {
const nextPositions = computeAutoFlowLayout(root, extraStartIds, extraRoots, orphans);
positionsRef.current = new Map(Object.entries(nextPositions));
edgeHandlesRef.current.clear();
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
setNodes(layoutNodes.map(n => ({
...n,
position: positionsRef.current.get(n.id) ?? n.position,
selected: nodesRef.current.find(p => p.id === n.id)?.selected ?? false,
})));
setEdges(mergedEdges);
scheduleLayoutEmit();
requestAnimationFrame(() => {
fitView({ padding: 0.35, duration: 320 });
});
}, [
root, extraStartIds, extraRoots, orphans, rebuildFlow, setNodes, setEdges,
scheduleLayoutEmit, fitView,
]);
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
const picked = selNodes.filter(n => !isStartNodeId(n.id));
if (picked.length === 1) onSelectNode(picked[0].id);
else onSelectNode(null);
if (selEdges.length === 1) {
@@ -622,8 +830,9 @@ function StrategyEditorCanvasInner({
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
root,
orphans,
extraRoots,
),
[root, orphans],
[root, orphans, extraRoots],
);
const applyResolvedConnection = useCallback((
@@ -639,7 +848,14 @@ function StrategyEditorCanvasInner({
if (childOrphan) {
onOrphansChange(orphans.filter(o => o.id !== childId));
connectOrphanIntoTree(wired, childOrphan, root, onChange);
connectOrphanIntoTree(
wired,
childOrphan,
root,
extraRoots,
onChange,
next => onExtraRootsChange?.(next),
);
scheduleLayoutEmit();
return;
}
@@ -650,10 +866,16 @@ function StrategyEditorCanvasInner({
return;
}
if (!root) return;
applyTreeConnection(parentId, childId, root, onChange);
applyTreeConnection(
parentId,
childId,
root,
extraRoots,
onChange,
next => onExtraRootsChange?.(next),
);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, scheduleLayoutEmit]);
}, [root, orphans, extraRoots, onChange, onExtraRootsChange, onOrphansChange, scheduleLayoutEmit]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
const positionChanges = changes.filter(
@@ -720,6 +942,7 @@ function StrategyEditorCanvasInner({
nodesSnapshot,
root,
orphans,
extraRoots,
);
orphanDragTargetRef.current = resolved;
if (resolved) {
@@ -766,13 +989,13 @@ function StrategyEditorCanvasInner({
]);
const onConnect = useCallback((conn: Connection) => {
const resolved = resolveStrategyConnection(conn, root, orphans);
const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots);
if (!resolved) return;
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
}, [root, orphans, applyResolvedConnection]);
}, [root, orphans, extraRoots, applyResolvedConnection]);
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
const resolved = resolveStrategyConnection(newConnection, root, orphans);
const resolved = resolveStrategyConnection(newConnection, root, orphans, extraRoots);
if (!resolved) return;
edgeHandlesRef.current.delete(oldEdge.id);
@@ -797,7 +1020,7 @@ function StrategyEditorCanvasInner({
positionsRef.current,
edgeHandlesRef.current,
));
}, [root, orphans, applyResolvedConnection, setEdges]);
}, [root, orphans, extraRoots, applyResolvedConnection, setEdges]);
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
@@ -815,6 +1038,10 @@ function StrategyEditorCanvasInner({
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
if (data.type === 'start') {
addStartAt(flowPos);
return;
}
const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') {
applyDropWithAttach(drop.anchorId, data, flowPos);
@@ -822,7 +1049,7 @@ function StrategyEditorCanvasInner({
addOrphanAt(data, flowPos);
}
} catch { /* ignore */ }
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, resolvePaletteDrop]);
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
const onPaneDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -917,6 +1144,22 @@ function StrategyEditorCanvasInner({
nodeColor={minimapNodeColor}
nodeStrokeColor={minimapNodeStrokeColor}
/>
<Panel position="top-right" className="se-canvas-auto-layout">
<button
type="button"
className="se-canvas-auto-layout-btn"
title="자동 정렬"
aria-label="노드 자동 정렬"
onClick={handleAutoLayout}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<path d="M14 17h7M17.5 14v7" />
</svg>
</button>
</Panel>
<Panel position="top-left" className="se-canvas-toolbar">
<span className="se-canvas-toolbar-title"> </span>
<div className="se-canvas-interaction" role="group" aria-label="캔버스 조작 모드">
@@ -10,6 +10,15 @@ import {
} from '../../utils/strategyEditorShared';
import { hasNodeSettings } from '../../utils/conditionPeriods';
import ConditionNodeSettings from './ConditionNodeSettings';
import {
type EditorConditionState,
addExtraStartSection,
updateBranchRoot,
updateStartCandleType,
removeStartSection,
collectStartSections,
} from '../../utils/strategyConditionSerde';
import { STRATEGY_CANDLE_TYPES } from '../../utils/strategyStartNodes';
const NODE_COLORS: Record<string, string> = {
AND: '#4caf50',
@@ -17,17 +26,20 @@ const NODE_COLORS: Record<string, string> = {
NOT: '#ff9800',
};
const IND_COLOR = '#9c27b0';
const LIST_DROP_KEY = '__list__';
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;
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => void;
dragOverKey?: string | null;
setDragOverKey?: (key: string | null) => void;
dropScope: string;
depth?: number;
def: DefType;
onAddStart?: () => void;
}
const TreeNodeComp: React.FC<TreeNodeProps> = ({
@@ -36,13 +48,16 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
onUpdate,
onDelete,
onDropOnNode,
dragOverId,
setDragOverId,
dragOverKey,
setDragOverKey,
dropScope,
depth = 0,
def,
onAddStart,
}) => {
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
const nodeDropKey = `${dropScope}:${node.id}`;
const [settingsOpen, setSettingsOpen] = useState(false);
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
const label = node.type === 'CONDITION' && node.condition
@@ -57,16 +72,20 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
if (!isLogic) return;
e.preventDefault();
e.stopPropagation();
setDragOverId?.(node.id);
setDragOverKey?.(nodeDropKey);
};
const handleDragLeave = () => setDragOverId?.(null);
const handleDragLeave = () => setDragOverKey?.(null);
const handleDrop = (e: React.DragEvent) => {
if (!isLogic) return;
e.preventDefault();
e.stopPropagation();
setDragOverId?.(null);
setDragOverKey?.(null);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (data.type === 'start') {
onAddStart?.();
return;
}
onDropOnNode?.(node.id, data);
} catch {
/* ignore */
@@ -81,7 +100,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
return (
<div
className={`sp-tree-node${dragOverId === node.id ? ' sp-tree-node--over' : ''}`}
className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`}
style={{ marginLeft: depth > 0 ? 12 : 0 }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -138,13 +157,15 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
onUpdate={u => handleChildUpdate(child.id, u)}
onDelete={() => handleChildDelete(child.id)}
onDropOnNode={onDropOnNode}
dragOverId={dragOverId}
setDragOverId={setDragOverId}
dragOverKey={dragOverKey}
setDragOverKey={setDragOverKey}
dropScope={dropScope}
depth={depth + 1}
def={def}
onAddStart={onAddStart}
/>
))}
{dragOverId === node.id && (
{dragOverKey === nodeDropKey && (
<div className="sp-drop-hint-inner"> </div>
)}
</div>
@@ -153,37 +174,64 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
);
};
interface Props {
interface StartSectionBlockProps {
startId: string;
candleType: string;
root: LogicNode | null;
canDelete: boolean;
signalTab: 'buy' | 'sell';
def: DefType;
onChange: (root: LogicNode | null) => void;
dragOverKey: string | null;
setDragOverKey: (key: string | null) => void;
onRootChange: (root: LogicNode | null) => void;
onCandleTypeChange: (candleType: string) => void;
onDeleteStart?: () => void;
onAddStart?: () => void;
}
export default function StrategyListEditor({ root, signalTab, def, onChange }: Props) {
const [dragOverId, setDragOverId] = useState<string | null>(null);
function StartSectionBlock({
startId,
candleType,
root,
canDelete,
signalTab,
def,
dragOverKey,
setDragOverKey,
onRootChange,
onCandleTypeChange,
onDeleteStart,
onAddStart,
}: StartSectionBlockProps) {
const sectionDropKey = `${startId}:__root__`;
const applyDrop = useCallback((
targetId: string | null,
data: { type: string; value: string; label: string; composite?: boolean },
) => {
if (data.type === 'start') {
onAddStart?.();
return;
}
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
if (!root) {
onChange(newNode);
onRootChange(newNode);
return;
}
if (!targetId) {
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
onRootChange(mergeAtRoot(root, newNode, data.type === 'operator'));
return;
}
onChange(addChild(root, targetId, newNode));
}, [root, signalTab, def, onChange]);
onRootChange(addChild(root, targetId, newNode));
}, [root, signalTab, def, onRootChange, onAddStart]);
const handleRootDrop = (e: React.DragEvent) => {
const handleSectionDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOverId(null);
e.stopPropagation();
setDragOverKey(null);
try {
applyDrop(null, JSON.parse(e.dataTransfer.getData('application/json')));
const data = JSON.parse(e.dataTransfer.getData('application/json'));
applyDrop(null, data);
} catch {
/* ignore */
}
@@ -193,27 +241,153 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
applyDrop(targetId, data);
};
return (
<section className="sp-start-section">
<header className="sp-start-head">
<span className="sp-start-dot" aria-hidden />
<span className="sp-start-label">START</span>
<select
className="sp-start-tf"
value={candleType}
title="조건 판별 시간봉"
onChange={e => onCandleTypeChange(e.target.value)}
>
{STRATEGY_CANDLE_TYPES.map(ct => (
<option key={ct} value={ct}>{ct}</option>
))}
</select>
{canDelete && (
<button type="button" className="sp-start-del" title="START 삭제" onClick={onDeleteStart}>×</button>
)}
</header>
<div
className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`}
onDragOver={e => {
e.preventDefault();
e.stopPropagation();
setDragOverKey(sectionDropKey);
}}
onDragLeave={() => setDragOverKey(null)}
onDrop={handleSectionDrop}
>
{!root ? (
<div className="sp-drop-empty sp-drop-empty--section">
· [{candleType}] .
</div>
) : (
<TreeNodeComp
node={root}
signalType={signalTab}
onUpdate={onRootChange}
onDelete={() => onRootChange(null)}
onDropOnNode={handleDropOnNode}
dragOverKey={dragOverKey}
setDragOverKey={setDragOverKey}
dropScope={startId}
def={def}
onAddStart={onAddStart}
/>
)}
</div>
</section>
);
}
interface Props {
editorState: EditorConditionState;
signalTab: 'buy' | 'sell';
def: DefType;
onEditorStateChange: (next: EditorConditionState) => void;
onAddStart?: () => void;
onOrphansChange?: (nodes: LogicNode[]) => void;
orphans?: LogicNode[];
}
export default function StrategyListEditor({
editorState,
signalTab,
def,
onEditorStateChange,
onAddStart,
onOrphansChange,
orphans = [],
}: Props) {
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
const sections = collectStartSections(editorState);
const handleAddStart = useCallback(() => {
if (onAddStart) {
onAddStart();
return;
}
onEditorStateChange(addExtraStartSection(editorState));
}, [onAddStart, onEditorStateChange, editorState]);
const handleRootChange = useCallback((startId: string, root: LogicNode | null) => {
onEditorStateChange(updateBranchRoot(editorState, startId, root));
}, [editorState, onEditorStateChange]);
const handleCandleTypeChange = useCallback((startId: string, candleType: string) => {
onEditorStateChange(updateStartCandleType(editorState, startId, candleType));
}, [editorState, onEditorStateChange]);
const handleDeleteStart = useCallback((startId: string) => {
const { state, orphanedNodes } = removeStartSection(editorState, startId);
onEditorStateChange(state);
if (orphanedNodes.length && onOrphansChange) {
onOrphansChange([...orphans, ...orphanedNodes]);
}
}, [editorState, onEditorStateChange, onOrphansChange, orphans]);
const handleListDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setDragOverKey(LIST_DROP_KEY);
};
const handleListDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverKey(null);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (data.type === 'start') {
handleAddStart();
}
} catch {
/* ignore */
}
};
return (
<div
className="se-list-editor sp-dropzone"
onDragOver={e => e.preventDefault()}
onDrop={handleRootDrop}
className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`}
onDragOver={handleListDragOver}
onDragLeave={() => setDragOverKey(null)}
onDrop={handleListDrop}
>
{!root ? (
<div className="sp-drop-empty">
.
</div>
) : (
<TreeNodeComp
node={root}
signalType={signalTab}
onUpdate={onChange}
onDelete={() => onChange(null)}
onDropOnNode={handleDropOnNode}
dragOverId={dragOverId}
setDragOverId={setDragOverId}
{sections.map(section => (
<StartSectionBlock
key={section.startId}
startId={section.startId}
candleType={section.candleType}
root={section.root}
canDelete={section.canDelete}
signalTab={signalTab}
def={def}
dragOverKey={dragOverKey}
setDragOverKey={setDragOverKey}
onRootChange={root => handleRootChange(section.startId, root)}
onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)}
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
onAddStart={handleAddStart}
/>
))}
{sections.length === 0 && (
<div className="sp-drop-empty">
START를 .
</div>
)}
</div>
);