Files
goldenChart/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
T
2026-05-25 17:56:25 +09:00

1205 lines
41 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
Panel,
useNodesState,
useEdgesState,
useReactFlow,
ConnectionMode,
SelectionMode,
type Connection,
type Edge,
type Node,
type OnNodesChange,
type OnSelectionChangeFunc,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index';
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
import {
addChild,
deleteNode,
mergeAtRoot,
makeNode,
makeNodeOptionsFromPalette,
updateNode,
type DefType,
} from '../../utils/strategyEditorShared';
import {
START_NODE_ID,
applyEdgeHandles,
buildConnectionFromPositions,
canAcceptPaletteAsParent,
connectionSidesFromDrop,
disconnectTreeEdge,
extractNode,
findNodeAtFlowPosition,
findNodeInTree,
FLOW_NODE_H,
FLOW_NODE_W,
getNodeDimensions,
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';
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
import { getMinimapColors } from './minimapTheme';
import {
loadCanvasInteractionMode,
saveCanvasInteractionMode,
type StrategyCanvasInteractionMode,
} from '../../utils/strategyCanvasInteractionStorage';
import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage';
export type FlowLayoutSeed = {
seedKey: string;
positions: Record<string, { x: number; y: number }>;
edgeHandles: Record<string, EdgeHandleBinding>;
};
const nodeTypes = {
start: StartNode,
logic: LogicGateNode,
condition: ConditionFlowNode,
};
const edgeTypes = {
strategy: StrategyFlowEdge,
};
interface Props {
theme: Theme;
root: LogicNode | null;
orphans: LogicNode[];
onOrphansChange: (nodes: LogicNode[]) => void;
def: DefType;
signalTab: 'buy' | 'sell';
onChange: (root: LogicNode | null) => void;
selectedNodeId: string | null;
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[] {
if (!node) return [];
const ids = [node.id];
for (const c of node.children ?? []) ids.push(...collectTreeIds(c));
return ids;
}
function saveEdgeHandles(
handles: Map<string, EdgeHandleBinding>,
edgeId: string,
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
lockHandles = false,
) {
handles.set(edgeId, {
sourceHandle: conn.sourceHandle,
targetHandle: conn.targetHandle,
manual: lockHandles,
manualSource: lockHandles,
manualTarget: lockHandles,
});
}
function applyTreeConnection(
sourceId: string,
targetId: string,
root: LogicNode | null,
extraRoots: Record<string, LogicNode | null>,
onChange: (root: LogicNode | null) => void,
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
) {
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;
}
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 (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[]) {
const ids = new Set(edges.map(e => e.id));
for (const id of handles.keys()) {
if (!ids.has(id)) handles.delete(id);
}
}
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 (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;
onChange(addChild(root, sourceId, orphan));
}
function connectOrphanAsParent(
conn: Connection,
orphan: LogicNode,
root: LogicNode | null,
onChange: (root: LogicNode | null) => void,
): void {
if (!root) return;
const { tree, node: moved } = extractNode(root, conn.target!);
if (!moved) return;
const isOp = ['AND', 'OR', 'NOT'].includes(orphan.type);
const combined = { ...orphan, children: [...(orphan.children ?? []), moved] };
onChange(tree ? mergeAtRoot(tree, combined, isOp) : combined);
}
function StrategyEditorCanvasInner({
theme,
root,
orphans,
onOrphansChange,
def,
signalTab,
onChange,
selectedNodeId,
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 }>());
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
const structureKeyRef = useRef<string | null>(null);
const orphanKeyRef = useRef<string | null>(null);
const layoutEmitTimerRef = useRef<number | null>(null);
const [interactionMode, setInteractionMode] = useState<StrategyCanvasInteractionMode>(
() => loadCanvasInteractionMode(),
);
const handleInteractionModeChange = useCallback((mode: StrategyCanvasInteractionMode) => {
setInteractionMode(mode);
saveCanvasInteractionMode(mode);
}, []);
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const nodesRef = useRef(nodes);
const edgesRef = useRef(edges);
nodesRef.current = nodes;
edgesRef.current = edges;
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, ...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, extraStartIds, extraRoots]);
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
const minimapNodeColor = useCallback((node: Node) => {
if (isStartNodeId(node.id)) return minimapColors.start;
if (node.type === 'condition') return minimapColors.condition;
return minimapColors.logic;
}, [minimapColors]);
const minimapNodeStrokeColor = useCallback((node: Node) => {
if (isStartNodeId(node.id)) return minimapColors.start;
return minimapColors.stroke;
}, [minimapColors]);
const signalTabRef = useRef(signalTab);
signalTabRef.current = signalTab;
const emitLayoutChange = useCallback((tab: 'buy' | 'sell') => {
if (!onLayoutChange) return;
onLayoutChange({
tab,
positions: Object.fromEntries(positionsRef.current),
edgeHandles: Object.fromEntries(edgeHandlesRef.current),
});
}, [onLayoutChange]);
const scheduleLayoutEmit = useCallback(() => {
if (!onLayoutChange) return;
const tabAtSchedule = signalTabRef.current;
if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current);
layoutEmitTimerRef.current = window.setTimeout(() => {
layoutEmitTimerRef.current = null;
emitLayoutChange(tabAtSchedule);
}, 120);
}, [onLayoutChange, emitLayoutChange]);
useEffect(() => () => {
if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current);
}, []);
useEffect(() => {
positionsRef.current = new Map(Object.entries(layoutSeed.positions));
edgeHandlesRef.current = new Map(Object.entries(layoutSeed.edgeHandles));
}, [layoutSeed.edgeHandles, layoutSeed.positions, layoutSeed.seedKey]);
useEffect(() => {
layoutFlushRef.current = () => {
if (layoutEmitTimerRef.current != null) {
window.clearTimeout(layoutEmitTimerRef.current);
layoutEmitTimerRef.current = null;
}
for (const n of nodesRef.current) {
positionsRef.current.set(n.id, { x: n.position.x, y: n.position.y });
}
emitLayoutChange(signalTabRef.current);
};
return () => {
layoutFlushRef.current = null;
};
}, [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 (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 && (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;
}
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 && !isStartNodeId(n.id))
.map(n => n.id);
if (!ids.length) return;
const orphanIds = new Set(ids.filter(id => isOrphanNode(orphans, id)));
if (orphanIds.size) {
onOrphansChange(orphans.filter(o => !orphanIds.has(o.id)));
orphanIds.forEach(id => positionsRef.current.delete(id));
}
const treeIds = ids.filter(id => !orphanIds.has(id));
if (treeIds.length && root) {
let tree: LogicNode | null = root;
for (const id of treeIds) {
if (!tree) break;
if (tree.id === id) {
tree = null;
positionsRef.current.delete(id);
continue;
}
const next = deleteNode(tree, id);
if (next !== tree) positionsRef.current.delete(id);
tree = next;
}
onChange(tree);
}
onSelectNode(null);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, onSelectNode, scheduleLayoutEmit]);
const clearDropPreview = useCallback(() => {
setNodes(nds => nds.map(n => ({
...n,
data: {
...n.data,
dragOver: false,
activeSourceSide: undefined,
},
})));
}, [setNodes]);
const updateDropPreview = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
const canParent = canAcceptPaletteAsParent(anchorId, root);
if (!canParent) {
setNodes(nds => nds.map(n => ({
...n,
data: { ...n.data, dragOver: false, activeSourceSide: undefined },
})));
return;
}
const dim = getNodeDimensions(anchorId);
const { sourceSide } = connectionSidesFromDrop(anchor.position, dim, flowPos);
setNodes(nds => nds.map(n => ({
...n,
data: {
...n.data,
dragOver: n.id === anchorId,
activeSourceSide: n.id === anchorId ? sourceSide : undefined,
},
})));
}, [setNodes, root]);
const orphanDragTargetRef = useRef<{ parentId: string; childId: string } | null>(null);
const updateOrphanConnectPreview = useCallback((
resolved: { parentId: string; childId: string },
draggedOrphanId: string,
orphanPos: { x: number; y: number },
) => {
const { parentId, childId } = resolved;
const nodesSnapshot = nodesRef.current;
const parentNode = nodesSnapshot.find(n => n.id === parentId);
const childNode = nodesSnapshot.find(n => n.id === childId);
if (!parentNode || !childNode) return;
const parentDim = getNodeDimensions(parentId);
const childDim = getNodeDimensions(childId);
const parentPos = parentId === draggedOrphanId ? orphanPos : parentNode.position;
const childPos = childId === draggedOrphanId ? orphanPos : childNode.position;
const childCenter = {
x: childPos.x + childDim.w / 2,
y: childPos.y + childDim.h / 2,
};
const { sourceSide } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
setNodes(nds => nds.map(n => ({
...n,
data: {
...n.data,
dragOver: n.id === parentId,
activeSourceSide: n.id === parentId ? sourceSide : undefined,
},
})));
}, [setNodes]);
const addOrphanAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
positionsRef.current.set(newNode.id, {
x: flowPos.x - FLOW_NODE_W / 2,
y: flowPos.y - FLOW_NODE_H / 2,
});
onOrphansChange([...orphans, newNode]);
scheduleLayoutEmit();
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback((
anchorId: string,
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, makeNodeOptionsFromPalette(data));
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
const anchorDim = getNodeDimensions(anchorId);
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
positionsRef.current.set(newNode.id, spawnPositionNearAnchor(anchor.position, anchorDim, sourceSide));
const saveAttachHandles = (parentId: string) => {
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
};
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)
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
if (anchorNode?.type === 'CONDITION') {
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
return;
}
if (!canAcceptPaletteAsParent(anchorId, root)) return;
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, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
updateDropPreview(anchorId, flowPos);
}, [updateDropPreview]);
const handleDragLeaveTarget = useCallback((anchorId: string) => {
setNodes(nds => nds.map(n => (
n.id === anchorId
? { ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined } }
: n
)));
}, [setNodes]);
const handleDropTarget = useCallback((
anchorId: string,
data: { type: string; value: string; label: string },
flowPos: { x: number; y: number },
) => {
clearDropPreview();
if (isPaletteConnectTarget(anchorId, root, orphans)) {
applyDropWithAttach(anchorId, data, flowPos);
} else {
addOrphanAt(data, flowPos);
}
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
if (isOrphanNode(orphans, id)) {
onOrphansChange(orphans.map(o => (
o.id === id ? { ...o, condition } : o
)));
return;
}
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,
onUpdateCondition: handleUpdateCondition,
onDropTarget: handleDropTarget,
onDragOverTarget: handleDragOverTarget,
onDragLeaveTarget: handleDragLeaveTarget,
onStartCandleTypeChange: handleStartCandleTypeChange,
onDeleteStart: handleDeleteStart,
}), [
handleDelete, handleUpdateCondition, handleDropTarget,
handleDragOverTarget, handleDragLeaveTarget,
handleStartCandleTypeChange, handleDeleteStart,
]);
const rebuildFlow = useCallback(() => logicNodeToFlow(
root,
def,
signalTab,
positionsRef.current,
flowCallbacks,
null,
orphans,
startMeta,
extraStartIds,
extraRoots,
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
useEffect(() => {
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
pruneEdgeHandles(edgeHandlesRef.current, mergedEdges);
const structureChanged = structureKeyRef.current !== treeStructureKey;
const orphansChanged = orphanKeyRef.current !== orphanKey;
structureKeyRef.current = treeStructureKey;
orphanKeyRef.current = orphanKey;
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
const prev = nodesRef.current.find(p => p.id === n.id);
const savedPos = layoutSeed.positions[n.id];
return {
...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position,
selected: prev?.selected ?? false,
};
}));
const hasSavedLayout = layoutNodes.some(
n => n.id !== START_NODE_ID && layoutSeed.positions[n.id] != null,
);
const needsNodeSync = layoutNodes.length !== nodesRef.current.length
|| !nodesRef.current.some(n => n.id === START_NODE_ID);
if (structureChanged || orphansChanged || needsNodeSync) {
syncNodesFromLayout();
setEdges(mergedEdges);
if (structureChanged && !hasSavedLayout) {
requestAnimationFrame(() => {
fitView({ padding: 0.4, duration: 200 });
});
}
} else {
setNodes(prev => prev.map(n => {
const fresh = layoutNodes.find(ln => ln.id === n.id);
if (!fresh) return n;
const savedPos = layoutSeed.positions[n.id];
return {
...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position,
data: fresh.data,
};
}));
setEdges(mergedEdges);
}
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView, layoutSeed]);
const handleDisconnectEdge = useCallback((edgeId: string) => {
const edge = edgesRef.current.find(e => e.id === edgeId);
if (!edge?.source || !edge.target) return;
edgeHandlesRef.current.delete(edgeId);
const { root: nextRoot, orphans: nextOrphans, extraRoots: nextExtraRoots } = disconnectTreeEdge(
edge.source,
edge.target,
root,
orphans,
extraRoots,
);
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, extraRoots, onChange, onOrphansChange, onExtraRootsChange, onSelectNode, setEdges]);
useEffect(() => {
edgeDisconnectRef.current = handleDisconnectEdge;
edgeBindingUpdateRef.current = (edgeId, patch) => {
const prev = edgeHandlesRef.current.get(edgeId) ?? {};
const next: EdgeHandleBinding = { ...prev, ...patch };
if (patch.sourceHandle != null) next.manualSource = true;
if (patch.targetHandle != null) next.manualTarget = true;
if (patch.centerX != null || patch.centerY != null) next.manualCenter = true;
if (next.manualSource && next.manualTarget) next.manual = true;
edgeHandlesRef.current.set(edgeId, next);
setEdges(prevEdges => applyEdgeHandles(prevEdges, positionsRef.current, edgeHandlesRef.current));
scheduleLayoutEmit();
};
return () => {
edgeDisconnectRef.current = null;
edgeBindingUpdateRef.current = null;
};
}, [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 => !isStartNodeId(n.id));
if (picked.length === 1) onSelectNode(picked[0].id);
else onSelectNode(null);
if (selEdges.length === 1) {
// 연결선만 선택된 경우 노드 선택 해제
if (picked.length === 0) onSelectNode(null);
}
}, [onSelectNode]);
const isValidConnection = useCallback(
(conn: Connection | Edge) => isValidStrategyConnection(
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
root,
orphans,
extraRoots,
),
[root, orphans, extraRoots],
);
const applyResolvedConnection = useCallback((
parentId: string,
childId: string,
conn: Connection,
) => {
const wired: Connection = { ...conn, source: parentId, target: childId };
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${childId}`, wired);
const childOrphan = orphans.find(o => o.id === childId);
const parentOrphan = orphans.find(o => o.id === parentId);
if (childOrphan) {
onOrphansChange(orphans.filter(o => o.id !== childId));
connectOrphanIntoTree(
wired,
childOrphan,
root,
extraRoots,
onChange,
next => onExtraRootsChange?.(next),
);
scheduleLayoutEmit();
return;
}
if (parentOrphan) {
onOrphansChange(orphans.filter(o => o.id !== parentId));
connectOrphanAsParent(wired, parentOrphan, root, onChange);
scheduleLayoutEmit();
return;
}
applyTreeConnection(
parentId,
childId,
root,
extraRoots,
onChange,
next => onExtraRootsChange?.(next),
);
scheduleLayoutEmit();
}, [root, orphans, extraRoots, onChange, onExtraRootsChange, onOrphansChange, scheduleLayoutEmit]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
const positionChanges = changes.filter(
(ch): ch is Extract<typeof ch, { type: 'position' }> => ch.type === 'position' && !!ch.position,
);
const startChange = positionChanges.find(ch => ch.id === START_NODE_ID);
const otherTreePositionChanges = positionChanges.filter(
ch => ch.id !== START_NODE_ID && treeLinkedIds.has(ch.id),
);
let startGroupDelta: { dx: number; dy: number } | null = null;
const startPos = startChange?.position;
if (startChange && startPos && otherTreePositionChanges.length === 0) {
const prev = positionsRef.current.get(START_NODE_ID);
if (prev) {
startGroupDelta = {
dx: startPos.x - prev.x,
dy: startPos.y - prev.y,
};
}
}
onNodesChangeBase(changes);
let positionsChanged = false;
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
for (const id of treeLinkedIds) {
if (id === START_NODE_ID) continue;
const p = positionsRef.current.get(id);
if (p) {
positionsRef.current.set(id, {
x: p.x + startGroupDelta.dx,
y: p.y + startGroupDelta.dy,
});
}
}
setNodes(nds => nds.map(n => {
if (!treeLinkedIds.has(n.id)) return n;
const p = positionsRef.current.get(n.id);
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
}));
positionsChanged = true;
}
for (const ch of positionChanges) {
if (!ch.position) continue;
if (startGroupDelta && treeLinkedIds.has(ch.id)) continue;
if (isOrphanNode(orphans, ch.id)) {
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
if (ch.dragging === true) {
positionsChanged = true;
const nodesSnapshot = nodesRef.current.map(n => (
n.id === ch.id
? { id: n.id, position: ch.position! }
: { id: n.id, position: n.position }
));
const resolved = resolveOrphanDragConnection(
ch.id,
ch.position,
nodesSnapshot,
root,
orphans,
extraRoots,
);
orphanDragTargetRef.current = resolved;
if (resolved) {
updateOrphanConnectPreview(resolved, ch.id, ch.position);
} else {
clearDropPreview();
}
} else if (ch.dragging === false) {
const pending = orphanDragTargetRef.current;
orphanDragTargetRef.current = null;
clearDropPreview();
if (pending) {
const conn = buildConnectionFromPositions(
pending.parentId,
pending.childId,
positionsRef.current,
);
applyResolvedConnection(pending.parentId, pending.childId, conn);
}
positionsChanged = true;
}
continue;
}
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
positionsChanged = true;
}
if (positionsChanged) {
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
scheduleLayoutEmit();
}
}, [
onNodesChangeBase,
setNodes,
setEdges,
treeLinkedIds,
orphans,
root,
clearDropPreview,
updateOrphanConnectPreview,
applyResolvedConnection,
scheduleLayoutEmit,
]);
const onConnect = useCallback((conn: Connection) => {
const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots);
if (!resolved) return;
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
}, [root, orphans, extraRoots, applyResolvedConnection]);
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
const resolved = resolveStrategyConnection(newConnection, root, orphans, extraRoots);
if (!resolved) return;
edgeHandlesRef.current.delete(oldEdge.id);
const endpointsChanged = oldEdge.source !== resolved.parentId || oldEdge.target !== resolved.childId;
if (endpointsChanged) {
applyResolvedConnection(resolved.parentId, resolved.childId, newConnection);
return;
}
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection, true);
setEdges(prev => applyEdgeHandles(
prev.map(e => (
e.id === oldEdge.id
? {
...e,
sourceHandle: newConnection.sourceHandle,
targetHandle: newConnection.targetHandle,
}
: e
)),
positionsRef.current,
edgeHandlesRef.current,
));
}, [root, orphans, extraRoots, applyResolvedConnection, setEdges]);
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => {
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
return { mode: 'connect' as const, anchorId: hit.id };
}
return { mode: 'orphan' as const };
}, [root, orphans]);
const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
clearDropPreview();
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);
} else {
addOrphanAt(data, flowPos);
}
} catch { /* ignore */ }
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
const onPaneDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!isPaletteDrag(e)) return;
e.dataTransfer.dropEffect = 'copy';
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') {
updateDropPreview(drop.anchorId, flowPos);
} else {
clearDropPreview();
}
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
const rel = e.relatedTarget as Element | null;
if (rel && e.currentTarget.contains(rel)) return;
clearDropPreview();
}, [clearDropPreview]);
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const selectedEdge = edgesRef.current.find(ed => ed.selected);
if (selectedEdge) {
e.preventDefault();
handleDisconnectEdge(selectedEdge.id);
return;
}
const selectedCount = nodesRef.current.filter(n => n.selected && n.id !== START_NODE_ID).length;
if (selectedCount > 1) {
e.preventDefault();
deleteSelectedNodes();
} else if (selectedNodeId && selectedNodeId !== START_NODE_ID) {
e.preventDefault();
handleDelete(selectedNodeId);
}
}, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]);
return (
<div
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
onKeyDown={onKeyDown}
tabIndex={0}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onSelectionChange={onSelectionChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onReconnect={onReconnect}
onDrop={onPaneDrop}
onDragOver={onPaneDragOver}
onDragLeave={onPaneDragLeave}
connectionMode={ConnectionMode.Loose}
edgesReconnectable
reconnectRadius={24}
minZoom={0.25}
maxZoom={1.8}
nodesDraggable
nodesConnectable
elementsSelectable
selectionOnDrag={interactionMode === 'select'}
selectionMode={SelectionMode.Partial}
panOnDrag={interactionMode === 'pan' ? true : [1, 2]}
selectNodesOnDrag={false}
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
deleteKeyCode={null}
defaultEdgeOptions={{
type: 'strategy',
className: 'se-flow-edge',
style: { strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
>
<Background gap={24} size={1} className="se-flow-bg" />
<Controls className="se-flow-controls" showInteractive={false} />
<MiniMap
className="se-flow-minimap"
zoomable
pannable
nodeStrokeWidth={2}
bgColor={minimapColors.bg}
maskColor={minimapColors.mask}
maskStrokeColor={minimapColors.maskStroke}
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="캔버스 조작 모드">
<button
type="button"
className={`se-canvas-interaction-btn${interactionMode === 'pan' ? ' se-canvas-interaction-btn--on' : ''}`}
title="빈 영역 드래그로 그래프 이동"
onClick={() => handleInteractionModeChange('pan')}
>
이동
</button>
<button
type="button"
className={`se-canvas-interaction-btn${interactionMode === 'select' ? ' se-canvas-interaction-btn--on' : ''}`}
title="드래그로 전략 요소 다중 선택"
onClick={() => handleInteractionModeChange('select')}
>
드래그
</button>
</div>
<span className="se-canvas-toolbar-hint">
{interactionMode === 'pan'
? '빈 영역 드래그 → 그래프 이동 · 노드 드래그 → 위치 변경'
: '빈 영역 드래그 → 다중 선택 · Del 삭제 · 연결선 × 끊기'}
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
</span>
</Panel>
{!root && (
<Panel position="top-center" className="se-canvas-empty">
우측에서 지표·연산자를 드래그하거나 클릭하여 조건을 구성하세요
</Panel>
)}
<MultiSelectionDeleteButton onDelete={deleteSelectedNodes} />
</ReactFlow>
</div>
);
}
export default function StrategyEditorCanvas(props: Props) {
return <StrategyEditorCanvasInner {...props} />;
}