From 1465cb2255efb4ce644b2d6a40c6936894f7361c Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 24 May 2026 12:25:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 166 +++++++++++++++- .../src/components/TradeSignalSnackbar.tsx | 27 ++- .../strategyEditor/StrategyEditorCanvas.tsx | 184 +++++++++++++----- .../strategyEditor/StrategyFlowEdge.tsx | 169 +++++++++++++++- .../strategyEditor/strategyEditorCallbacks.ts | 12 ++ .../src/contexts/TradeNotificationContext.tsx | 22 ++- frontend/src/styles/strategyEditor.css | 7 + frontend/src/utils/strategyEdgeInteraction.ts | 71 +++++++ .../src/utils/strategyEditorLayoutStorage.ts | 69 +++++++ frontend/src/utils/strategyFlowLayout.ts | 70 ++++++- 10 files changed, 714 insertions(+), 83 deletions(-) create mode 100644 frontend/src/utils/strategyEdgeInteraction.ts create mode 100644 frontend/src/utils/strategyEditorLayoutStorage.ts diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index a448ae2..627b4ff 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(null); const [selectedNodeId, setSelectedNodeId] = useState(null); - const [buyOrphans, setBuyOrphans] = useState([]); - const [sellOrphans, setSellOrphans] = useState([]); + const initialDraftBuy = readTabLayout('draft', 'buy'); + const initialDraftSell = readTabLayout('draft', 'sell'); + const [buyOrphans, setBuyOrphans] = useState(() => initialDraftBuy.orphans ?? []); + const [sellOrphans, setSellOrphans] = useState(() => 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(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 @@ -406,6 +550,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro onChange={setCurrentRoot} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} + layoutSeed={layoutSeed} + onLayoutChange={handleLayoutChange} /> diff --git a/frontend/src/components/TradeSignalSnackbar.tsx b/frontend/src/components/TradeSignalSnackbar.tsx index d416e91..1c43bfc 100644 --- a/frontend/src/components/TradeSignalSnackbar.tsx +++ b/frontend/src/components/TradeSignalSnackbar.tsx @@ -45,7 +45,6 @@ export const TradeSignalSnackbar: React.FC = ({ toastNotifications, dismissToast, dismissToasts, - dismissAllToasts, openDetail, } = useTradeNotification(); @@ -94,17 +93,23 @@ export const TradeSignalSnackbar: React.FC = ({ }, [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,17 +226,19 @@ export const TradeSignalSnackbar: React.FC = ({ type="button" className="tsn-dismiss-page" onClick={dismissCurrentPage} - title={`현재 화면 알림 ${pageItems.length}건 닫기`} + disabled={pageItems.length === 0} + title={`현재 화면에 보이는 알림 ${pageItems.length}건 닫기`} > - 알림창닫기 ({pageItems.length}) + 알림창 닫기 ({pageItems.length}) {showSlideNav && ( diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 8541123..f61ca32 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -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; + edgeHandles: Record; +}; 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, edgeId: string, conn: Pick, - 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()); const edgeHandlesRef = useRef(new Map()); - const structureKeyRef = useRef(''); - const orphanKeyRef = useRef(''); + const structureKeyRef = useRef(null); + const orphanKeyRef = useRef(null); + const layoutEmitTimerRef = useRef(null); const [nodes, setNodes, onNodesChangeBase] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -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,16 +757,20 @@ function StrategyEditorCanvasInner({ return; } - saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection); - setEdges(prev => prev.map(e => ( - e.id === oldEdge.id - ? { - ...e, - sourceHandle: newConnection.sourceHandle, - targetHandle: newConnection.targetHandle, - } - : e - ))); + 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, applyResolvedConnection, setEdges]); const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json'); diff --git a/frontend/src/components/strategyEditor/StrategyFlowEdge.tsx b/frontend/src/components/strategyEditor/StrategyFlowEdge.tsx index 6e75df5..132c8a0 100644 --- a/frontend/src/components/strategyEditor/StrategyFlowEdge.tsx +++ b/frontend/src/components/strategyEditor/StrategyFlowEdge.tsx @@ -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(null); + + const commitBinding = useCallback((patch: EdgeHandleBinding) => { + edgeBindingUpdateRef.current?.(id, patch); + }, [id]); + + const positionsFromFlow = useCallback(() => { + return buildPositionsFromNodes(getNodes()); + }, [getNodes]); + + const onHitPointerDown = useCallback((e: React.PointerEvent) => { + 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) => { + 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 ( <> + {selected && edgeDisconnectRef.current && ( @@ -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); }} > diff --git a/frontend/src/components/strategyEditor/strategyEditorCallbacks.ts b/frontend/src/components/strategyEditor/strategyEditorCallbacks.ts index 6f12626..7756808 100644 --- a/frontend/src/components/strategyEditor/strategyEditorCallbacks.ts +++ b/frontend/src/components/strategyEditor/strategyEditorCallbacks.ts @@ -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) => void) | null, +}; + +/** 탭 전환 직전 — 현재 탭 레이아웃 즉시 저장 */ +export const layoutFlushRef = { + current: null as (() => void) | null, +}; diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index cfa5e37..f4216c3 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -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 = ({ }, []); 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 => { diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index e88f292..b622cee 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -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; diff --git a/frontend/src/utils/strategyEdgeInteraction.ts b/frontend/src/utils/strategyEdgeInteraction.ts new file mode 100644 index 0000000..c15fe36 --- /dev/null +++ b/frontend/src/utils/strategyEdgeInteraction.ts @@ -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 { + 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 { + 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 { + const map = new Map(); + 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; +} diff --git a/frontend/src/utils/strategyEditorLayoutStorage.ts b/frontend/src/utils/strategyEditorLayoutStorage.ts new file mode 100644 index 0000000..f520ff3 --- /dev/null +++ b/frontend/src/utils/strategyEditorLayoutStorage.ts @@ -0,0 +1,69 @@ +import type { LogicNode } from './strategyTypes'; +import type { EdgeHandleBinding } from './strategyFlowLayout'; + +export type SignalFlowLayoutSnapshot = { + positions: Record; + edgeHandles: Record; + orphans?: LogicNode[]; +}; + +export type FlowLayoutChangePayload = Omit & { + 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; + 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 = 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; + 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); +} diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts index f6d791e..ee20cf0 100644 --- a/frontend/src/utils/strategyFlowLayout.ts +++ b/frontend/src/utils/strategyFlowLayout.ts @@ -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, +): { 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, saved: Map, ): 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 }, + }; }); }