1581 lines
54 KiB
TypeScript
1581 lines
54 KiB
TypeScript
import React, { useCallback, useEffect, useLayoutEffect, 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,
|
|
type ReactFlowInstance,
|
|
} from '@xyflow/react';
|
|
import '@xyflow/react/dist/style.css';
|
|
import type { Theme } from '../../types/index';
|
|
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
|
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
|
|
import {
|
|
addChild,
|
|
deleteNode,
|
|
mergeAtRoot,
|
|
makeNode,
|
|
makeNodeOptionsFromPalette,
|
|
updateNode,
|
|
type DefType,
|
|
} from '../../utils/strategyEditorShared';
|
|
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
|
import {
|
|
isPaletteHtmlDrag,
|
|
isPaletteHtmlDragActive,
|
|
getLastHtmlPaletteDragClientXY,
|
|
isPaletteDragSessionActive,
|
|
markPaletteHtmlDragEnd,
|
|
needsPointerPaletteDrag,
|
|
readPaletteHtmlDragData,
|
|
setHtmlPaletteDropRouter,
|
|
setPaletteDragDropHandler,
|
|
subscribePaletteDrag,
|
|
type PaletteDragEvent,
|
|
} from '../../utils/paletteDragSession';
|
|
import {
|
|
isOverStrategyBuilder,
|
|
resolvePaletteDropClientPoint,
|
|
} from '../../utils/flowScreenProjection';
|
|
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
|
import {
|
|
setPriceExtremePairFilterLevel,
|
|
type PriceExtremeFilterLevel,
|
|
} from '../../utils/priceExtremeBreakoutPair';
|
|
import {
|
|
setInflection33PairFilterLevel,
|
|
type Inflection33FilterLevel,
|
|
} from '../../utils/inflection33Strategy';
|
|
import {
|
|
setStableStrategyPairFilterLevel,
|
|
type StableStrategyFilterLevel,
|
|
} from '../../utils/stableStrategyPairs';
|
|
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,
|
|
type StrategyFlowNodeData,
|
|
} 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;
|
|
/** START 체크박스 — startMeta + 조건 트리 분봉 동기화 */
|
|
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => 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,
|
|
onStartCandleTypesChange,
|
|
onExtraStartIdsChange,
|
|
onExtraRootsChange,
|
|
}: Props) {
|
|
const { fitView } = useReactFlow();
|
|
const canvasWrapRef = useRef<HTMLDivElement>(null);
|
|
const reactFlowRef = useRef<ReactFlowInstance | null>(null);
|
|
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 handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
|
if (onStartCandleTypesChange) {
|
|
onStartCandleTypesChange(startId, candleTypes);
|
|
return;
|
|
}
|
|
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
|
|
const normalized = types.map(normalizeStartCandleType);
|
|
onStartMetaChange?.({
|
|
...startMeta,
|
|
[startId]: { candleTypes: normalized, candleType: normalized[0] },
|
|
});
|
|
}, [startMeta, onStartMetaChange, onStartCandleTypesChange]);
|
|
|
|
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]: { candleTypes: [DEFAULT_START_CANDLE], 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 resolvePaletteDropNode = useCallback((
|
|
data: { type: string; value: string; label: string; composite?: boolean },
|
|
): LogicNode | null => {
|
|
if (data.type === 'sidewaysFilter') {
|
|
return buildSidewaysFilterNode(data.value, def);
|
|
}
|
|
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
|
}, [signalTab, def]);
|
|
|
|
const addOrphanAt = useCallback((
|
|
data: { type: string; value: string; label: string; composite?: boolean },
|
|
flowPos: { x: number; y: number },
|
|
) => {
|
|
const newNode = resolvePaletteDropNode(data);
|
|
if (!newNode) return;
|
|
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, resolvePaletteDropNode, scheduleLayoutEmit]);
|
|
|
|
const applyDropWithAttach = useCallback((
|
|
anchorId: string,
|
|
data: { type: string; value: string; label: string; composite?: boolean },
|
|
flowPos: { x: number; y: number },
|
|
): boolean => {
|
|
if (data.type === 'start') {
|
|
addStartAt(flowPos);
|
|
return true;
|
|
}
|
|
|
|
const newNode = resolvePaletteDropNode(data);
|
|
if (!newNode) return false;
|
|
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
|
if (!anchor) return false;
|
|
|
|
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 true;
|
|
}
|
|
|
|
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 true;
|
|
}
|
|
|
|
if (!canAcceptPaletteAsParent(anchorId, root)) return false;
|
|
|
|
if (root && findNodeInTree(root, anchorId)) {
|
|
onChange(addChild(root, anchorId, newNode));
|
|
saveAttachHandles(anchorId);
|
|
scheduleLayoutEmit();
|
|
return true;
|
|
}
|
|
|
|
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();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
|
|
|
|
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)) {
|
|
const attached = applyDropWithAttach(anchorId, data, flowPos);
|
|
if (!attached) addOrphanAt(data, flowPos);
|
|
} else {
|
|
addOrphanAt(data, flowPos);
|
|
}
|
|
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
|
|
|
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
|
const next = normalizeConditionForPersistence(condition);
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (
|
|
o.id === id ? { ...o, condition: next } : o
|
|
)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, n => ({ ...n, condition: next })));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, n => ({ ...n, condition: next })),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const handleReplaceLogicNode = useCallback((id: string, replaced: LogicNode) => {
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (o.id === id ? replaced : o)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, () => replaced));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, () => replaced),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
|
const patchGate = (n: LogicNode) => (
|
|
n.type === 'AND' || n.type === 'OR'
|
|
? {
|
|
...n,
|
|
type: gateType,
|
|
stochPair: gateType === 'OR' ? n.stochPair : undefined,
|
|
priceExtremePair: gateType === 'AND' ? n.priceExtremePair : undefined,
|
|
inflection33Pair: n.inflection33Pair?.mode === 'buy'
|
|
? (gateType === 'AND' ? n.inflection33Pair : undefined)
|
|
: n.inflection33Pair?.mode === 'sell'
|
|
? (gateType === 'OR' ? n.inflection33Pair : undefined)
|
|
: undefined,
|
|
stableStrategyPair: n.stableStrategyPair?.mode === 'buy'
|
|
? (gateType === 'AND' ? n.stableStrategyPair : undefined)
|
|
: n.stableStrategyPair?.mode === 'sell'
|
|
? (gateType === 'OR' ? n.stableStrategyPair : undefined)
|
|
: undefined,
|
|
}
|
|
: n
|
|
);
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (
|
|
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
|
? patchGate(o)
|
|
: o
|
|
)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, patchGate));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, patchGate),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const handleUpdateStochPairSecondary = useCallback((id: string, secondaryIndicator: string) => {
|
|
const patchPair = (n: LogicNode) => setStochPairSecondary(n, secondaryIndicator, def);
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, patchPair));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, patchPair),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const handleUpdatePriceExtremeFilterLevel = useCallback((id: string, filterLevel: PriceExtremeFilterLevel) => {
|
|
const patchPair = (n: LogicNode) => setPriceExtremePairFilterLevel(n, filterLevel, def);
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, patchPair));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, patchPair),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const handleUpdateInflection33FilterLevel = useCallback((id: string, filterLevel: Inflection33FilterLevel) => {
|
|
const patchPair = (n: LogicNode) => setInflection33PairFilterLevel(n, filterLevel, def);
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, patchPair));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, patchPair),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const handleUpdateStableStrategyFilterLevel = useCallback((id: string, filterLevel: StableStrategyFilterLevel) => {
|
|
const patchPair = (n: LogicNode) => setStableStrategyPairFilterLevel(n, filterLevel, def);
|
|
if (isOrphanNode(orphans, id)) {
|
|
onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o)));
|
|
return;
|
|
}
|
|
if (root && findNodeInTree(root, id)) {
|
|
onChange(updateNode(root, id, patchPair));
|
|
return;
|
|
}
|
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
|
if (branch && findNodeInTree(branch, id)) {
|
|
onExtraRootsChange?.({
|
|
...extraRoots,
|
|
[sid]: updateNode(branch, id, patchPair),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]);
|
|
|
|
const flowCallbacks = useMemo(() => ({
|
|
onDelete: handleDelete,
|
|
onUpdateCondition: handleUpdateCondition,
|
|
onReplaceLogicNode: handleReplaceLogicNode,
|
|
onUpdateStochPairSecondary: handleUpdateStochPairSecondary,
|
|
onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel,
|
|
onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel,
|
|
onUpdateStableStrategyFilterLevel: handleUpdateStableStrategyFilterLevel,
|
|
onChangeLogicGateType: handleChangeLogicGateType,
|
|
onDropTarget: handleDropTarget,
|
|
onDragOverTarget: handleDragOverTarget,
|
|
onDragLeaveTarget: handleDragLeaveTarget,
|
|
onStartCandleTypesChange: handleStartCandleTypesChange,
|
|
onDeleteStart: handleDeleteStart,
|
|
}), [
|
|
handleDelete, handleUpdateCondition, handleReplaceLogicNode, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType,
|
|
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
|
handleStartCandleTypesChange, 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(() => {
|
|
if (isPaletteDragSessionActive()) return;
|
|
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];
|
|
const dragOver = (prev?.data as StrategyFlowNodeData | undefined)?.dragOver;
|
|
const activeSourceSide = (prev?.data as StrategyFlowNodeData | undefined)?.activeSourceSide;
|
|
return {
|
|
...n,
|
|
position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position,
|
|
selected: prev?.selected ?? false,
|
|
data: {
|
|
...n.data,
|
|
...(dragOver ? { dragOver, activeSourceSide } : {}),
|
|
},
|
|
};
|
|
}));
|
|
|
|
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];
|
|
const dragOver = (n.data as StrategyFlowNodeData).dragOver;
|
|
const activeSourceSide = (n.data as StrategyFlowNodeData).activeSourceSide;
|
|
return {
|
|
...n,
|
|
position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position,
|
|
data: {
|
|
...fresh.data,
|
|
...(dragOver ? { dragOver, activeSourceSide } : {}),
|
|
},
|
|
};
|
|
}));
|
|
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 resolveDropFromClient = useCallback((clientX: number, clientY: number) => (
|
|
resolvePaletteDropClientPoint(
|
|
clientX,
|
|
clientY,
|
|
reactFlowRef.current,
|
|
canvasWrapRef.current,
|
|
nodesRef.current,
|
|
findNodeAtFlowPosition,
|
|
nodeId => isPaletteConnectTarget(nodeId, root, orphans),
|
|
)
|
|
), [root, orphans]);
|
|
|
|
const isPaletteDrag = useCallback(
|
|
(e: React.DragEvent) => isPaletteHtmlDragActive() || isPaletteHtmlDrag(e),
|
|
[],
|
|
);
|
|
|
|
const applyPaletteDropAt = useCallback((
|
|
data: { type: string; value: string; label: string; composite?: boolean },
|
|
clientX: number,
|
|
clientY: number,
|
|
) => {
|
|
const { flowPos, anchorId } = resolveDropFromClient(clientX, clientY);
|
|
const overCanvas = isOverStrategyBuilder(clientX, clientY);
|
|
if (!overCanvas && !anchorId) return;
|
|
if (data.type === 'start') {
|
|
addStartAt(flowPos);
|
|
return;
|
|
}
|
|
|
|
if (anchorId) {
|
|
handleDropTarget(anchorId, data, flowPos);
|
|
return;
|
|
}
|
|
|
|
addOrphanAt(data, flowPos);
|
|
|
|
requestAnimationFrame(() => {
|
|
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
|
|
});
|
|
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
|
|
|
|
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
clearDropPreview();
|
|
const data = readPaletteHtmlDragData(e);
|
|
if (!data) return;
|
|
applyPaletteDropAt(data, e.clientX, e.clientY);
|
|
}, [clearDropPreview, applyPaletteDropAt]);
|
|
|
|
const applyDropRef = useRef(applyPaletteDropAt);
|
|
applyDropRef.current = applyPaletteDropAt;
|
|
const clearPreviewRef = useRef(clearDropPreview);
|
|
clearPreviewRef.current = clearDropPreview;
|
|
|
|
useLayoutEffect(() => {
|
|
if (needsPointerPaletteDrag()) return undefined;
|
|
setHtmlPaletteDropRouter((x, y, payload) => {
|
|
clearPreviewRef.current();
|
|
applyDropRef.current(payload, x, y);
|
|
});
|
|
return () => setHtmlPaletteDropRouter(null);
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!needsPointerPaletteDrag()) return undefined;
|
|
setPaletteDragDropHandler((ev) => {
|
|
if (ev.phase === 'end') {
|
|
clearPreviewRef.current();
|
|
applyDropRef.current(ev.payload, ev.x, ev.y);
|
|
}
|
|
});
|
|
return () => setPaletteDragDropHandler(null);
|
|
}, []);
|
|
|
|
const updatePreviewRef = useRef(updateDropPreview);
|
|
updatePreviewRef.current = updateDropPreview;
|
|
const resolveDropFromClientRef = useRef(resolveDropFromClient);
|
|
resolveDropFromClientRef.current = resolveDropFromClient;
|
|
const lastPreviewAnchorRef = useRef<string | null>(null);
|
|
const lastPreviewKeyRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (needsPointerPaletteDrag()) return undefined;
|
|
let rafId = 0;
|
|
const tick = () => {
|
|
if (!isPaletteHtmlDragActive()) {
|
|
rafId = 0;
|
|
return;
|
|
}
|
|
const xy = getLastHtmlPaletteDragClientXY();
|
|
if (xy && (xy.x !== 0 || xy.y !== 0)) {
|
|
const { flowPos, anchorId } = resolveDropFromClientRef.current(xy.x, xy.y);
|
|
if (anchorId) {
|
|
updatePreviewRef.current(anchorId, flowPos);
|
|
lastPreviewAnchorRef.current = anchorId;
|
|
} else if (lastPreviewAnchorRef.current != null) {
|
|
lastPreviewAnchorRef.current = null;
|
|
clearPreviewRef.current();
|
|
}
|
|
}
|
|
rafId = requestAnimationFrame(tick);
|
|
};
|
|
const onDragStart = () => {
|
|
if (!rafId) rafId = requestAnimationFrame(tick);
|
|
};
|
|
document.addEventListener('dragstart', onDragStart, true);
|
|
return () => {
|
|
document.removeEventListener('dragstart', onDragStart, true);
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (needsPointerPaletteDrag()) return undefined;
|
|
const onDocDragOver = (e: DragEvent) => {
|
|
if (!isPaletteHtmlDragActive()) return;
|
|
e.preventDefault();
|
|
const { flowPos, anchorId } = resolveDropFromClientRef.current(e.clientX, e.clientY);
|
|
if (anchorId) {
|
|
updatePreviewRef.current(anchorId, flowPos);
|
|
lastPreviewAnchorRef.current = anchorId;
|
|
}
|
|
};
|
|
document.addEventListener('dragover', onDocDragOver, { passive: false, capture: true });
|
|
return () => document.removeEventListener('dragover', onDocDragOver, { capture: true });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onDocDragEnd = () => {
|
|
if (!isPaletteHtmlDragActive()) return;
|
|
lastPreviewAnchorRef.current = null;
|
|
lastPreviewKeyRef.current = null;
|
|
clearPreviewRef.current();
|
|
markPaletteHtmlDragEnd();
|
|
};
|
|
document.addEventListener('dragend', onDocDragEnd);
|
|
return () => {
|
|
document.removeEventListener('dragend', onDocDragEnd);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return subscribePaletteDrag((ev: PaletteDragEvent) => {
|
|
if (ev.phase === 'move') {
|
|
const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y);
|
|
if (anchorId) {
|
|
lastPreviewAnchorRef.current = anchorId;
|
|
lastPreviewKeyRef.current = `${anchorId}:${Math.round(flowPos.x / 8)}:${Math.round(flowPos.y / 8)}`;
|
|
updatePreviewRef.current(anchorId, flowPos);
|
|
return;
|
|
}
|
|
if (lastPreviewAnchorRef.current != null) {
|
|
lastPreviewAnchorRef.current = null;
|
|
lastPreviewKeyRef.current = null;
|
|
clearPreviewRef.current();
|
|
}
|
|
return;
|
|
}
|
|
if (ev.phase === 'end' || ev.phase === 'cancel') {
|
|
lastPreviewAnchorRef.current = null;
|
|
lastPreviewKeyRef.current = null;
|
|
}
|
|
if (ev.phase === 'cancel') {
|
|
clearPreviewRef.current();
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const onPaneDragEnter = useCallback((e: React.DragEvent) => {
|
|
if (!isPaletteDrag(e)) return;
|
|
e.preventDefault();
|
|
}, [isPaletteDrag]);
|
|
|
|
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
if (!isPaletteDrag(e)) return;
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY);
|
|
if (anchorId) {
|
|
updateDropPreview(anchorId, flowPos);
|
|
}
|
|
}, [resolveDropFromClient, updateDropPreview, isPaletteDrag]);
|
|
|
|
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
|
|
const rel = e.relatedTarget as Element | null;
|
|
if (!rel) return;
|
|
if (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
|
|
ref={canvasWrapRef}
|
|
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
|
|
onKeyDown={onKeyDown}
|
|
tabIndex={0}
|
|
>
|
|
<ReactFlow
|
|
onInit={inst => { reactFlowRef.current = inst; }}
|
|
nodes={nodes}
|
|
edges={edges}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onSelectionChange={onSelectionChange}
|
|
onConnect={onConnect}
|
|
isValidConnection={isValidConnection}
|
|
onReconnect={onReconnect}
|
|
onDrop={onPaneDrop}
|
|
onDragEnter={onPaneDragEnter}
|
|
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} />;
|
|
}
|