전략편집기 수정

This commit is contained in:
Macbook
2026-05-24 12:25:17 +09:00
parent ea39f7df27
commit 1465cb2255
10 changed files with 714 additions and 83 deletions
+156 -10
View File
@@ -19,8 +19,18 @@ import {
type StrategyDto,
} from '../utils/strategyEditorShared';
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
import {
emptySignalFlowLayout,
loadStrategyFlowLayout,
migrateStrategyFlowLayout,
saveStrategyFlowLayout,
deleteStrategyFlowLayout,
type SignalFlowLayoutSnapshot,
type FlowLayoutChangePayload,
} from '../utils/strategyEditorLayoutStorage';
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
import StrategyEditorCanvas from './strategyEditor/StrategyEditorCanvas';
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
import PaletteChip from './strategyEditor/PaletteChip';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import '../styles/strategyEditor.css';
@@ -38,6 +48,17 @@ interface Props {
activeIndicators?: IndicatorConfig[];
}
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
const stored = loadStrategyFlowLayout(strategyKey);
const snap = stored?.[tab];
if (!snap) return emptySignalFlowLayout();
return {
positions: snap.positions ?? {},
edgeHandles: snap.edgeHandles ?? {},
orphans: snap.orphans ?? [],
};
}
export default function StrategyEditorPage({ theme, activeIndicators = [] }: Props) {
const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]);
@@ -55,17 +76,36 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
const initialDraftBuy = readTabLayout('draft', 'buy');
const initialDraftSell = readTabLayout('draft', 'sell');
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
const [saveToast, setSaveToast] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const terminalHeightRef = useRef(terminalHeight);
const [buyLayout, setBuyLayout] = useState(() => ({
positions: initialDraftBuy.positions,
edgeHandles: initialDraftBuy.edgeHandles,
}));
const [sellLayout, setSellLayout] = useState(() => ({
positions: initialDraftSell.positions,
edgeHandles: initialDraftSell.edgeHandles,
}));
const buyLayoutRef = useRef(buyLayout);
const sellLayoutRef = useRef(sellLayout);
buyLayoutRef.current = buyLayout;
sellLayoutRef.current = sellLayout;
const layoutRevisionRef = useRef(0);
const persistLayoutTimerRef = useRef<number | null>(null);
const layoutPersistReadyRef = useRef(false);
leftWidthRef.current = leftWidth;
terminalHeightRef.current = terminalHeight;
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
const onLeftSplitter = usePanelResize(
'vertical',
setLeftWidth,
@@ -106,6 +146,105 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
const orphanTotal = buyOrphans.length + sellOrphans.length;
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
const persistFlowLayout = useCallback((strategyKey: string) => {
saveStrategyFlowLayout(strategyKey, {
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
});
}, [buyOrphans, sellOrphans]);
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
persistLayoutTimerRef.current = window.setTimeout(() => {
persistLayoutTimerRef.current = null;
persistFlowLayout(strategyKey);
}, 150);
}, [persistFlowLayout]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`);
}, []);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(emptyBuy);
setSellLayout(emptySell);
setBuyOrphans([]);
setSellOrphans([]);
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const stored = loadStrategyFlowLayout(strategyKey);
if (stored) {
const nextBuy = {
positions: stored.buy.positions ?? {},
edgeHandles: stored.buy.edgeHandles ?? {},
};
const nextSell = {
positions: stored.sell.positions ?? {},
edgeHandles: stored.sell.edgeHandles ?? {},
};
setBuyLayout(nextBuy);
setSellLayout(nextSell);
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(emptyBuy);
setSellLayout(emptySell);
setBuyOrphans([]);
setSellOrphans([]);
}
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
const next = {
positions: snapshot.positions,
edgeHandles: snapshot.edgeHandles,
};
if (snapshot.tab === 'buy') setBuyLayout(next);
else setSellLayout(next);
schedulePersistFlowLayout(layoutStrategyKey);
}, [layoutStrategyKey, schedulePersistFlowLayout]);
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
setSignalTab(tab);
setSelectedNodeId(null);
}, [layoutStrategyKey, persistFlowLayout]);
const layoutSeed = useMemo((): FlowLayoutSeed => {
const source = signalTab === 'buy' ? buyLayout : sellLayout;
return {
seedKey: layoutSeedKey,
positions: source.positions,
edgeHandles: source.edgeHandles,
};
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
useEffect(() => () => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
}, []);
useEffect(() => {
if (!layoutPersistReadyRef.current) {
layoutPersistReadyRef.current = true;
return;
}
schedulePersistFlowLayout(layoutStrategyKey);
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
useEffect(() => {
@@ -159,9 +298,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
setStratDesc(s.description ?? '');
setBuyCondition(s.buyCondition ?? null);
setSellCondition(s.sellCondition ?? null);
setBuyOrphans([]);
setSellOrphans([]);
setSelectedNodeId(null);
applyStoredFlowLayout(String(s.id), signalTab);
};
const handleNew = () => {
@@ -170,9 +308,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
setStratDesc('');
setBuyCondition(null);
setSellCondition(null);
setBuyOrphans([]);
setSellOrphans([]);
setSelectedNodeId(null);
resetFlowLayout('draft', signalTab);
};
const handleSave = async () => {
@@ -191,6 +328,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
const saved = await saveStrategy(payload);
const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now();
const wasDraft = selectedId == null;
setStrategies(prev => {
const existing = prev.find(s => s.id === selectedId);
if (existing && selectedId) {
@@ -202,6 +340,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
});
if (!selectedId) setSelectedId(dbId);
if (wasDraft) {
migrateStrategyFlowLayout('draft', String(dbId));
}
persistFlowLayout(String(dbId));
bumpLayoutSeed(String(dbId), signalTab);
setSaveOpen(false);
setSaveToast(true);
setTimeout(() => setSaveToast(false), 2500);
@@ -216,6 +359,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
const handleDeleteConfirm = async () => {
if (!deleteId) return;
try { await deleteStrategy(deleteId); } catch { /* local */ }
deleteStrategyFlowLayout(String(deleteId));
setStrategies(prev => prev.filter(s => s.id !== deleteId));
if (selectedId === deleteId) handleNew();
setDeleteOpen(false);
@@ -245,7 +389,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
id: genId(), type: 'CONDITION',
condition: { indicatorType: tmpl.ind, conditionType: tmpl.cond, leftField: tmpl.l, rightField: tmpl.r, candleRange: 1 },
};
if (tmpl.signal !== signalTab) setSignalTab(tmpl.signal);
if (tmpl.signal !== signalTab) switchSignalTab(tmpl.signal);
const root = tmpl.signal === 'buy' ? buyCondition : sellCondition;
const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition;
if (!root) setRoot(newNode);
@@ -380,14 +524,14 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
<button
type="button"
className={`se-signal-tab${signalTab === 'buy' ? ' se-signal-tab--buy-on' : ''}`}
onClick={() => { setSignalTab('buy'); setSelectedNodeId(null); }}
onClick={() => switchSignalTab('buy')}
>
(Entry)
</button>
<button
type="button"
className={`se-signal-tab${signalTab === 'sell' ? ' se-signal-tab--sell-on' : ''}`}
onClick={() => { setSignalTab('sell'); setSelectedNodeId(null); }}
onClick={() => switchSignalTab('sell')}
>
(Exit)
</button>
@@ -406,6 +550,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
onChange={setCurrentRoot}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
layoutSeed={layoutSeed}
onLayoutChange={handleLayoutChange}
/>
</ReactFlowProvider>
@@ -45,7 +45,6 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
toastNotifications,
dismissToast,
dismissToasts,
dismissAllToasts,
openDetail,
} = useTradeNotification();
@@ -94,17 +93,23 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
}, [totalPages]);
const dismissAll = useCallback(() => {
dismissAllToasts();
if (toastNotifications.length === 0) return;
dismissToasts(toastNotifications.map(n => n.id));
setPageIndex(0);
positionsRef.current = {};
}, [dismissAllToasts]);
}, [dismissToasts, toastNotifications]);
const dismissCurrentPage = useCallback(() => {
dismissToasts(pageItems.map(i => i.id));
const ids = pageItems.map(i => i.id);
if (ids.length === 0) return;
dismissToasts(ids);
positionsRef.current = Object.fromEntries(
Object.entries(positionsRef.current).filter(([id]) => !pageItems.some(p => p.id === id)),
Object.entries(positionsRef.current).filter(([id]) => !ids.includes(id)),
);
}, [dismissToasts, pageItems]);
const remaining = toastNotifications.length - ids.length;
const newTotalPages = Math.max(1, Math.ceil(remaining / pageSize));
setPageIndex(p => Math.min(p, newTotalPages - 1));
}, [dismissToasts, pageItems, toastNotifications.length, pageSize]);
const handleDragStart = useCallback((e: React.PointerEvent, key: string) => {
if (e.button !== 0) return;
@@ -221,7 +226,8 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
type="button"
className="tsn-dismiss-page"
onClick={dismissCurrentPage}
title={`현재 화면 알림 ${pageItems.length}건 닫기`}
disabled={pageItems.length === 0}
title={`현재 화면에 보이는 알림 ${pageItems.length}건 닫기`}
>
({pageItems.length})
</button>
@@ -229,7 +235,8 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
type="button"
className="tsn-dismiss-all"
onClick={dismissAll}
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기`}
disabled={toastNotifications.length === 0}
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기 (모든 페이지)`}
>
({toastNotifications.length})
</button>
@@ -51,9 +51,16 @@ import {
} from '../../utils/strategyFlowLayout';
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
import { StrategyFlowEdge } from './StrategyFlowEdge';
import { edgeDisconnectRef } from './strategyEditorCallbacks';
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
import { getMinimapColors } from './minimapTheme';
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,
@@ -75,6 +82,8 @@ interface Props {
onChange: (root: LogicNode | null) => void;
selectedNodeId: string | null;
onSelectNode: (id: string | null) => void;
layoutSeed: FlowLayoutSeed;
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
}
function collectTreeIds(node: LogicNode | null): string[] {
@@ -88,12 +97,14 @@ function saveEdgeHandles(
handles: Map<string, EdgeHandleBinding>,
edgeId: string,
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
manual = true,
lockHandles = false,
) {
handles.set(edgeId, {
sourceHandle: conn.sourceHandle,
targetHandle: conn.targetHandle,
manual,
manual: lockHandles,
manualSource: lockHandles,
manualTarget: lockHandles,
});
}
@@ -170,12 +181,15 @@ function StrategyEditorCanvasInner({
onChange,
selectedNodeId,
onSelectNode,
layoutSeed,
onLayoutChange,
}: 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('');
const orphanKeyRef = useRef('');
const structureKeyRef = useRef<string | null>(null);
const orphanKeyRef = useRef<string | null>(null);
const layoutEmitTimerRef = useRef<number | null>(null);
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -185,8 +199,8 @@ function StrategyEditorCanvasInner({
edgesRef.current = edges;
const treeStructureKey = useMemo(
() => `${signalTab}:${collectTreeIds(root).join('|')}`,
[root, signalTab],
() => collectTreeIds(root).join('|'),
[root],
);
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
@@ -210,6 +224,53 @@ function StrategyEditorCanvasInner({
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 handleDelete = useCallback((id: string) => {
if (id === START_NODE_ID) return;
if (isOrphanNode(orphans, id)) {
@@ -229,7 +290,8 @@ function StrategyEditorCanvasInner({
onChange(next);
positionsRef.current.delete(id);
if (selectedNodeId === id) onSelectNode(null);
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId]);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId, scheduleLayoutEmit]);
const deleteSelectedNodes = useCallback(() => {
const ids = nodesRef.current
@@ -260,7 +322,8 @@ function StrategyEditorCanvasInner({
onChange(tree);
}
onSelectNode(null);
}, [root, orphans, onChange, onOrphansChange, onSelectNode]);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, onSelectNode, scheduleLayoutEmit]);
const clearDropPreview = useCallback(() => {
setNodes(nds => nds.map(n => ({
@@ -341,7 +404,8 @@ function StrategyEditorCanvasInner({
y: flowPos.y - FLOW_NODE_H / 2,
});
onOrphansChange([...orphans, newNode]);
}, [orphans, onOrphansChange, signalTab, def]);
scheduleLayoutEmit();
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback((
anchorId: string,
@@ -363,11 +427,13 @@ function StrategyEditorCanvasInner({
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);
scheduleLayoutEmit();
return;
}
@@ -381,7 +447,8 @@ function StrategyEditorCanvasInner({
onChange(addChild(root, anchorId, newNode));
saveAttachHandles(anchorId);
}, [root, signalTab, def, onChange]);
scheduleLayoutEmit();
}, [root, signalTab, def, onChange, scheduleLayoutEmit]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
updateDropPreview(anchorId, flowPos);
@@ -425,20 +492,8 @@ function StrategyEditorCanvasInner({
orphans,
), [root, def, signalTab, flowCallbacks, orphans]);
useEffect(() => {
positionsRef.current = new Map();
edgeHandlesRef.current = new Map();
structureKeyRef.current = '';
orphanKeyRef.current = '';
onOrphansChange([]);
}, [signalTab, onOrphansChange]);
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
useEffect(() => {
for (const n of nodesRef.current) {
positionsRef.current.set(n.id, n.position);
}
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
pruneEdgeHandles(edgeHandlesRef.current, mergedEdges);
@@ -449,13 +504,25 @@ function StrategyEditorCanvasInner({
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
const prev = nodesRef.current.find(p => p.id === n.id);
return { ...n, selected: prev?.selected ?? false };
const savedPos = layoutSeed.positions[n.id];
return {
...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position,
selected: prev?.selected ?? false,
};
}));
if (structureChanged || orphansChanged) {
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) {
if (structureChanged && !hasSavedLayout) {
requestAnimationFrame(() => {
fitView({ padding: 0.4, duration: 200 });
});
@@ -464,11 +531,16 @@ function StrategyEditorCanvasInner({
setNodes(prev => prev.map(n => {
const fresh = layoutNodes.find(ln => ln.id === n.id);
if (!fresh) return n;
return { ...n, data: fresh.data };
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]);
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView, layoutSeed]);
const handleDisconnectEdge = useCallback((edgeId: string) => {
const edge = edgesRef.current.find(e => e.id === edgeId);
@@ -492,10 +564,22 @@ function StrategyEditorCanvasInner({
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]);
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
@@ -530,17 +614,20 @@ function StrategyEditorCanvasInner({
if (childOrphan) {
onOrphansChange(orphans.filter(o => o.id !== childId));
connectOrphanIntoTree(wired, childOrphan, root, onChange);
scheduleLayoutEmit();
return;
}
if (parentOrphan) {
onOrphansChange(orphans.filter(o => o.id !== parentId));
connectOrphanAsParent(wired, parentOrphan, root, onChange);
scheduleLayoutEmit();
return;
}
if (!root) return;
applyTreeConnection(parentId, childId, root, onChange);
}, [root, orphans, onChange, onOrphansChange]);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, scheduleLayoutEmit]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
const positionChanges = changes.filter(
@@ -565,7 +652,7 @@ function StrategyEditorCanvasInner({
onNodesChangeBase(changes);
let dragEnded = false;
let positionsChanged = false;
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
@@ -584,7 +671,7 @@ function StrategyEditorCanvasInner({
const p = positionsRef.current.get(n.id);
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
}));
if (startChange?.dragging === false) dragEnded = true;
positionsChanged = true;
}
for (const ch of positionChanges) {
@@ -595,6 +682,7 @@ function StrategyEditorCanvasInner({
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! }
@@ -625,17 +713,18 @@ function StrategyEditorCanvasInner({
);
applyResolvedConnection(pending.parentId, pending.childId, conn);
}
dragEnded = true;
positionsChanged = true;
}
continue;
}
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
if (ch.dragging === false) dragEnded = true;
positionsChanged = true;
}
if (dragEnded) {
if (positionsChanged) {
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
scheduleLayoutEmit();
}
}, [
onNodesChangeBase,
@@ -647,6 +736,7 @@ function StrategyEditorCanvasInner({
clearDropPreview,
updateOrphanConnectPreview,
applyResolvedConnection,
scheduleLayoutEmit,
]);
const onConnect = useCallback((conn: Connection) => {
@@ -667,8 +757,9 @@ function StrategyEditorCanvasInner({
return;
}
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection);
setEdges(prev => prev.map(e => (
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection, true);
setEdges(prev => applyEdgeHandles(
prev.map(e => (
e.id === oldEdge.id
? {
...e,
@@ -676,7 +767,10 @@ function StrategyEditorCanvasInner({
targetHandle: newConnection.targetHandle,
}
: e
)));
)),
positionsRef.current,
edgeHandlesRef.current,
));
}, [root, orphans, applyResolvedConnection, setEdges]);
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
@@ -1,8 +1,30 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
import { edgeDisconnectRef } from './strategyEditorCallbacks';
import React, { useCallback, useRef, useState } from 'react';
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
useReactFlow,
type EdgeProps,
} from '@xyflow/react';
import { bindWindowDrag } from '../../utils/pointerDrag';
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
import {
buildPositionsFromNodes,
getEdgeDragZone,
pickSourceHandleAtPoint,
pickTargetHandleAtPoint,
type EdgeDragZone,
} from '../../utils/strategyEdgeInteraction';
import { edgeBindingUpdateRef, edgeDisconnectRef } from './strategyEditorCallbacks';
type StrategyEdgeData = {
binding?: EdgeHandleBinding;
};
export function StrategyFlowEdge({
id,
source,
target,
sourceX,
sourceY,
targetX,
@@ -10,7 +32,12 @@ export function StrategyFlowEdge({
sourcePosition,
targetPosition,
selected,
data,
}: EdgeProps) {
const { screenToFlowPosition, getNodes } = useReactFlow();
const binding = (data as StrategyEdgeData | undefined)?.binding;
const hasCenter = binding?.manualCenter && binding.centerX != null && binding.centerY != null;
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
@@ -18,15 +45,147 @@ export function StrategyFlowEdge({
targetY,
sourcePosition,
targetPosition,
...(hasCenter ? { centerX: binding!.centerX!, centerY: binding!.centerY! } : {}),
});
const dragRef = useRef<{
zone: EdgeDragZone;
startCenterX: number;
startCenterY: number;
} | null>(null);
const unbindRef = useRef<(() => void) | null>(null);
const [dragZone, setDragZone] = useState<EdgeDragZone | null>(null);
const commitBinding = useCallback((patch: EdgeHandleBinding) => {
edgeBindingUpdateRef.current?.(id, patch);
}, [id]);
const positionsFromFlow = useCallback(() => {
return buildPositionsFromNodes(getNodes());
}, [getNodes]);
const onHitPointerDown = useCallback((e: React.PointerEvent<SVGPathElement>) => {
if (e.button !== 0) return;
if ((e.target as Element).closest('.se-flow-edge-del')) return;
e.preventDefault();
e.stopPropagation();
const flow = screenToFlowPosition({ x: e.clientX, y: e.clientY });
const zone = getEdgeDragZone(sourceX, sourceY, targetX, targetY, flow.x, flow.y);
setDragZone(zone);
try {
(e.currentTarget as SVGPathElement).setPointerCapture(e.pointerId);
} catch { /* ignore */ }
const positions = positionsFromFlow();
const startCenterX = binding?.centerX ?? labelX;
const startCenterY = binding?.centerY ?? labelY;
dragRef.current = { zone, startCenterX, startCenterY };
if (zone === 'source') {
commitBinding({
sourceHandle: pickSourceHandleAtPoint(source, flow, positions),
manualSource: true,
});
} else if (zone === 'target') {
commitBinding({
targetHandle: pickTargetHandleAtPoint(target, flow, positions),
manualTarget: true,
});
} else {
commitBinding({
centerX: flow.x,
centerY: flow.y,
manualCenter: true,
});
}
unbindRef.current?.();
unbindRef.current = bindWindowDrag(
({ x, y }) => {
const d = dragRef.current;
if (!d) return;
const pos = screenToFlowPosition({ x, y });
const positions = positionsFromFlow();
if (d.zone === 'source') {
commitBinding({
sourceHandle: pickSourceHandleAtPoint(source, pos, positions),
manualSource: true,
});
} else if (d.zone === 'target') {
commitBinding({
targetHandle: pickTargetHandleAtPoint(target, pos, positions),
manualTarget: true,
});
} else {
commitBinding({
centerX: pos.x,
centerY: pos.y,
manualCenter: true,
});
}
},
() => {
dragRef.current = null;
setDragZone(null);
unbindRef.current = null;
},
);
}, [
binding?.centerX,
binding?.centerY,
commitBinding,
labelX,
labelY,
positionsFromFlow,
screenToFlowPosition,
source,
sourceX,
sourceY,
target,
targetX,
targetY,
]);
const onHitPointerUp = useCallback((e: React.PointerEvent<SVGPathElement>) => {
dragRef.current = null;
setDragZone(null);
unbindRef.current?.();
unbindRef.current = null;
try {
(e.currentTarget as SVGPathElement).releasePointerCapture(e.pointerId);
} catch { /* ignore */ }
}, []);
const hitCursor = dragZone === 'source'
? 'grab'
: dragZone === 'target'
? 'grab'
: dragZone === 'middle'
? 'move'
: 'pointer';
return (
<>
<BaseEdge
id={id}
path={edgePath}
className={`se-flow-edge${selected ? ' se-flow-edge--selected' : ''}`}
style={{ strokeWidth: selected ? 3 : 2 }}
style={{ strokeWidth: selected ? 3 : 2, pointerEvents: 'none' }}
/>
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={22}
className={`se-flow-edge-hit${dragZone ? ` se-flow-edge-hit--${dragZone}` : ''}`}
style={{ cursor: hitCursor }}
onPointerDown={onHitPointerDown}
onPointerUp={onHitPointerUp}
onPointerCancel={onHitPointerUp}
/>
{selected && edgeDisconnectRef.current && (
<EdgeLabelRenderer>
@@ -39,8 +198,8 @@ export function StrategyFlowEdge({
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
onClick={(e) => {
e.stopPropagation();
onClick={(ev) => {
ev.stopPropagation();
edgeDisconnectRef.current?.(id);
}}
>
@@ -1,4 +1,16 @@
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
/** 연결선 끊기 — 커스텀 엣지 컴포넌트에서 캔버스 핸들러 참조 */
export const edgeDisconnectRef = {
current: null as ((edgeId: string) => void) | null,
};
/** 연결선 핸들·경로 바인딩 갱신 */
export const edgeBindingUpdateRef = {
current: null as ((edgeId: string, patch: Partial<EdgeHandleBinding>) => void) | null,
};
/** 탭 전환 직전 — 현재 탭 레이아웃 즉시 저장 */
export const layoutFlushRef = {
current: null as (() => void) | null,
};
@@ -42,8 +42,9 @@ interface TradeNotificationContextValue {
unreadCount: number;
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
dismissToast: (id: string) => void;
/** 지정 id 알림만 닫기 (현재 페이지 등) */
/** 지정 id 알림만 팝업 대기열에서 닫기 (읽음 처리) */
dismissToasts: (ids: string[]) => void;
/** 팝업 대기열(toastNotifications) 전체 닫기 — 모든 페이지 포함 */
dismissAllToasts: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
@@ -250,10 +251,21 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
}, []);
const dismissAllToasts = useCallback(() => {
const ids = toastNotificationsRef.current.map(n => n.id);
if (ids.length === 0) return;
dismissToasts(ids);
}, [dismissToasts]);
const snapshot = toastNotificationsRef.current;
if (snapshot.length === 0) return;
const idSet = new Set(snapshot.map(n => n.id));
setReadIds(r => {
const next = new Set(r);
idSet.forEach(i => next.add(i));
saveReadIds(next);
return next;
});
setToastNotifications([]);
setAllNotifications(all =>
all.map(n => (idSet.has(n.id) ? { ...n, isRead: true } : n)),
);
}, []);
const markAllAsRead = useCallback(() => {
setAllNotifications(prev => {
+7
View File
@@ -485,6 +485,13 @@
scale: 1.1;
}
.se-flow-edge-hit {
pointer-events: stroke;
}
.se-flow-edge-hit--source { cursor: grab; }
.se-flow-edge-hit--middle { cursor: move; }
.se-flow-edge-hit--target { cursor: grab; }
.se-selection-delete {
z-index: 12;
display: flex;
@@ -0,0 +1,71 @@
import {
getNodeDimensions,
pickSideFromPoint,
START_NODE_ID,
type HandleSide,
} from './strategyFlowLayout';
export type EdgeDragZone = 'source' | 'middle' | 'target';
/** 연결선 상 클릭/드래그 위치 → 좌(소스) / 중(경로) / 우(타겟) 구간 */
export function getEdgeDragZone(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
pointerX: number,
pointerY: number,
): EdgeDragZone {
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const len2 = dx * dx + dy * dy;
if (len2 < 4) return 'middle';
const t = ((pointerX - sourceX) * dx + (pointerY - sourceY) * dy) / len2;
const clamped = Math.max(0, Math.min(1, t));
if (clamped < 1 / 3) return 'source';
if (clamped > 2 / 3) return 'target';
return 'middle';
}
function sideToSourceHandle(side: HandleSide): string {
return `s-${side}`;
}
function sideToTargetHandle(side: HandleSide): string {
return `t-${side}`;
}
/** 드래그 위치에 맞는 소스(출발) 핸들 */
export function pickSourceHandleAtPoint(
nodeId: string,
flowPos: { x: number; y: number },
positions: Map<string, { x: number; y: number }>,
): string {
const pos = positions.get(nodeId) ?? { x: 0, y: 0 };
const dim = getNodeDimensions(nodeId);
return sideToSourceHandle(pickSideFromPoint(flowPos, pos, dim));
}
/** 드래그 위치에 맞는 타겟(도착) 핸들 */
export function pickTargetHandleAtPoint(
nodeId: string,
flowPos: { x: number; y: number },
positions: Map<string, { x: number; y: number }>,
): string {
const pos = positions.get(nodeId) ?? { x: 0, y: 0 };
const dim = getNodeDimensions(nodeId);
return sideToTargetHandle(pickSideFromPoint(flowPos, pos, dim));
}
export function buildPositionsFromNodes(
nodes: { id: string; position: { x: number; y: number } }[],
): Map<string, { x: number; y: number }> {
const map = new Map<string, { x: number; y: number }>();
for (const n of nodes) {
map.set(n.id, { x: n.position.x, y: n.position.y });
}
if (!map.has(START_NODE_ID)) {
map.set(START_NODE_ID, { x: 0, y: 220 });
}
return map;
}
@@ -0,0 +1,69 @@
import type { LogicNode } from './strategyTypes';
import type { EdgeHandleBinding } from './strategyFlowLayout';
export type SignalFlowLayoutSnapshot = {
positions: Record<string, { x: number; y: number }>;
edgeHandles: Record<string, EdgeHandleBinding>;
orphans?: LogicNode[];
};
export type FlowLayoutChangePayload = Omit<SignalFlowLayoutSnapshot, 'orphans'> & {
tab: 'buy' | 'sell';
};
export type StrategyFlowLayoutStore = {
buy: SignalFlowLayoutSnapshot;
sell: SignalFlowLayoutSnapshot;
};
const STORAGE_KEY = 'gc_se_flow_layout_v1';
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
return { positions: {}, edgeHandles: {}, orphans: [] };
}
export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
return { buy: emptySignalFlowLayout(), sell: emptySignalFlowLayout() };
}
export function loadStrategyFlowLayout(strategyKey: string): StrategyFlowLayoutStore | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
return all[strategyKey] ?? null;
} catch {
return null;
}
}
export function saveStrategyFlowLayout(strategyKey: string, layout: StrategyFlowLayoutStore): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const all: Record<string, StrategyFlowLayoutStore> = raw ? JSON.parse(raw) : {};
all[strategyKey] = layout;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore quota / private mode */
}
}
export function deleteStrategyFlowLayout(strategyKey: string): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
delete all[strategyKey];
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore */
}
}
export function migrateStrategyFlowLayout(fromKey: string, toKey: string): void {
if (fromKey === toKey) return;
const layout = loadStrategyFlowLayout(fromKey);
if (!layout) return;
saveStrategyFlowLayout(toKey, layout);
deleteStrategyFlowLayout(fromKey);
}
+62 -8
View File
@@ -301,7 +301,13 @@ export function buildConnectionFromPositions(
export type EdgeHandleBinding = {
sourceHandle?: string | null;
targetHandle?: string | null;
/** smooth-step 경로 굽힘 중심 (중간 영역 드래그) */
centerX?: number | null;
centerY?: number | null;
manual?: boolean;
manualSource?: boolean;
manualTarget?: boolean;
manualCenter?: boolean;
};
export const FLOW_NODE_W = 200;
@@ -340,21 +346,69 @@ export function pickHandleSides(
: { sourceHandle: 's-top', targetHandle: 't-bottom' };
}
/** 경로 굽힘 중심 기준 — 양쪽 노드에 가장 자연스러운 핸들 */
export function pickHandlesFromCenter(
sourceId: string,
targetId: string,
center: { x: number; y: number },
positions: Map<string, { x: number; y: number }>,
): { sourceHandle: string; targetHandle: string } {
const sourcePos = positions.get(sourceId) ?? { x: 0, y: 0 };
const targetPos = positions.get(targetId) ?? { x: 0, y: 0 };
const sourceDim = getNodeDimensions(sourceId);
const targetDim = getNodeDimensions(targetId);
const sourceSide = pickSideFromPoint(center, sourcePos, sourceDim);
const targetSide = pickSideFromPoint(center, targetPos, targetDim);
return {
sourceHandle: `s-${sourceSide}`,
targetHandle: `t-${targetSide}`,
};
}
export function applyEdgeHandles(
edges: Edge[],
positions: Map<string, { x: number; y: number }>,
saved: Map<string, EdgeHandleBinding>,
): Edge[] {
return edges.map(edge => {
const binding = saved.get(edge.id);
if (binding?.manual && binding.sourceHandle && binding.targetHandle) {
return { ...edge, sourceHandle: binding.sourceHandle, targetHandle: binding.targetHandle };
}
const binding = saved.get(edge.id) ?? {};
const picked = pickHandleSides(edge.source, edge.target, positions);
const sourceHandle = binding?.sourceHandle ?? picked.sourceHandle;
const targetHandle = binding?.targetHandle ?? picked.targetHandle;
saved.set(edge.id, { sourceHandle, targetHandle, manual: binding?.manual });
return { ...edge, sourceHandle, targetHandle };
let sourceHandle = picked.sourceHandle;
let targetHandle = picked.targetHandle;
if (
binding.manualCenter
&& binding.centerX != null
&& binding.centerY != null
) {
const natural = pickHandlesFromCenter(
edge.source,
edge.target,
{ x: binding.centerX, y: binding.centerY },
positions,
);
if (!binding.manualSource) sourceHandle = natural.sourceHandle;
else sourceHandle = binding.sourceHandle ?? picked.sourceHandle;
if (!binding.manualTarget) targetHandle = natural.targetHandle;
else targetHandle = binding.targetHandle ?? picked.targetHandle;
} else {
if (binding.manualSource) sourceHandle = binding.sourceHandle ?? picked.sourceHandle;
if (binding.manualTarget) targetHandle = binding.targetHandle ?? picked.targetHandle;
}
const nextBinding: EdgeHandleBinding = {
...binding,
sourceHandle,
targetHandle,
};
saved.set(edge.id, nextBinding);
return {
...edge,
sourceHandle,
targetHandle,
data: { ...(edge.data as object), binding: nextBinding },
};
});
}