다중 시간봉 전략 설정기능 추가
This commit is contained in:
@@ -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="캔버스 조작 모드">
|
||||
|
||||
Reference in New Issue
Block a user