diff --git a/frontend/src/flow/SystemFlowViewer.css b/frontend/src/flow/SystemFlowViewer.css index ab13810..30bce23 100644 --- a/frontend/src/flow/SystemFlowViewer.css +++ b/frontend/src/flow/SystemFlowViewer.css @@ -183,6 +183,48 @@ body, background: #0b1220; } +.sf-focus-badge { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(56, 189, 248, 0.45); + border-radius: 8px; + box-shadow: 0 10px 32px rgba(2, 6, 23, 0.5); + font-size: 12px; + color: #e2e8f0; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.sf-focus-badge strong { + color: #7dd3fc; + font-weight: 600; +} + +.sf-focus-badge button { + background: transparent; + border: 1px solid rgba(148, 163, 184, 0.3); + color: #94a3b8; + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; +} + +.sf-focus-badge button:hover { + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.5); + color: #7dd3fc; +} + .sf-canvas .react-flow__renderer { background: #0b1220; } @@ -265,7 +307,15 @@ body, } .sf-node-body[data-selected='true'] { - box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.35), 0 12px 32px rgba(2, 6, 23, 0.4); + box-shadow: + 0 0 0 4px rgba(56, 189, 248, 0.35), + 0 0 32px rgba(56, 189, 248, 0.25), + 0 12px 32px rgba(2, 6, 23, 0.4); + transform: scale(1.02); +} + +.sf-node-body[data-dimmed='true'] { + filter: grayscale(0.8); } .sf-node-stage { diff --git a/frontend/src/flow/SystemFlowViewer.tsx b/frontend/src/flow/SystemFlowViewer.tsx index 9f6eb0d..b0bb194 100644 --- a/frontend/src/flow/SystemFlowViewer.tsx +++ b/frontend/src/flow/SystemFlowViewer.tsx @@ -44,8 +44,8 @@ function matchesSearch(node: FlowNode, query: string): boolean { // ─── 레이아웃 계산 (stage/menu 그리드) ────────────────── -const COL_WIDTH = 360; -const ROW_HEIGHT = 130; +const COL_WIDTH = 440; +const ROW_HEIGHT = 170; const COL_PADDING_X = 80; const COL_PADDING_Y = 80; @@ -60,6 +60,7 @@ function buildLayout( groupBy: 'stage' | 'menu', selectedNodeId: string | null, selectedEdgeId: string | null, + focusedNodeIds: Set | null, ): LayoutResult { // 1. 그룹 키별로 노드 분류 (stage 모드는 정해진 순서, menu 모드는 정렬) const STAGE_ORDER = [ @@ -99,6 +100,7 @@ function buildLayout( const y = COL_PADDING_Y + rowIdx * ROW_HEIGHT; const accent = stageColor(node.stage); const isSelected = selectedNodeId === node.id; + const isDimmed = focusedNodeIds !== null && !focusedNodeIds.has(node.id); positionedNodes.push({ id: node.id, @@ -110,11 +112,11 @@ function buildLayout( background: 'transparent', border: 'none', width: 240, + opacity: isDimmed ? 0.18 : 1, + transition: 'opacity 0.2s', + pointerEvents: isDimmed ? 'none' : 'auto', }, - // 커스텀 노드를 사용하지 않고 기본 노드의 라벨을 React 요소로 전달 - // (별도 nodeTypes 등록 없이 동작하도록 className으로 처리) } as Node); - // 노드 라벨에 직접 JSX를 넣을 수 없으므로 className으로 처리하고 CSS로 dataset 사용 const last = positionedNodes[positionedNodes.length - 1]; last.data = { label: ( @@ -123,6 +125,7 @@ function buildLayout( data-status={node.status} data-trigger={node.trigger} data-selected={isSelected} + data-dimmed={isDimmed} style={{ ['--accent' as never]: accent }} >
{node.stage} · {node.kind}
@@ -135,7 +138,8 @@ function buildLayout( }); }); - // 3. 엣지 변환 + // 3. 엣지 변환 — source별 offset으로 겹침 완화 + const sourceCounter: Record = {}; const positionedEdges: Edge[] = flowEdges.map((edge) => { const isSelected = selectedEdgeId === edge.id; const color = @@ -144,26 +148,49 @@ function buildLayout( : edge.kind === 'trigger' ? '#86efac' : '#94a3b8'; + + // 같은 source에서 나가는 N번째 엣지에 offset 증가 → 경로 분산 + const idxFromSource = sourceCounter[edge.source] ?? 0; + sourceCounter[edge.source] = idxFromSource + 1; + // 20~80px 사이에서 분산 (노드 너비 240 기준) + const pathOffset = 20 + idxFromSource * 14; + + // 포커스 모드: 선택 노드와 연결된 엣지만 불투명 + const isFocused = + focusedNodeIds === null || + (focusedNodeIds.has(edge.source) && focusedNodeIds.has(edge.target)); + const isHighlighted = + isSelected || + (selectedNodeId !== null && + (edge.source === selectedNodeId || edge.target === selectedNodeId)); + + const strokeColor = isHighlighted ? '#f8fafc' : color; + const opacity = isFocused ? 1 : 0.08; + return { id: edge.id, source: edge.source, target: edge.target, label: edge.label, type: 'smoothstep', - animated: isSelected || edge.kind === 'feedback', + pathOptions: { offset: pathOffset, borderRadius: 14 }, + animated: isHighlighted || (isFocused && edge.kind === 'feedback'), markerEnd: { type: MarkerType.ArrowClosed, - color: isSelected ? '#f8fafc' : color, + color: strokeColor, width: 18, height: 18, }, style: { - stroke: isSelected ? '#f8fafc' : color, - strokeWidth: isSelected ? 2.6 : 1.6, + stroke: strokeColor, + strokeWidth: isHighlighted ? 2.8 : 1.6, strokeDasharray: edge.kind === 'feedback' ? '6 4' : undefined, + opacity, + transition: 'opacity 0.2s, stroke 0.2s, stroke-width 0.2s', }, - labelStyle: { fill: '#cbd5e1', fontSize: 10 }, - labelBgStyle: { fill: '#0f172a', fillOpacity: 0.9 }, + labelStyle: { fill: isFocused ? '#cbd5e1' : '#475569', fontSize: 10 }, + labelBgStyle: { fill: '#0f172a', fillOpacity: isFocused ? 0.9 : 0.3 }, + zIndex: isHighlighted ? 1000 : isFocused ? 10 : 0, }; }); @@ -230,10 +257,30 @@ export function SystemFlowViewer() { ); }, [filteredNodeIds]); + // 포커스 모드: 선택된 노드가 있으면 1-hop 연결 노드 집합 계산 + // 선택이 없거나 엣지가 선택되면 null (전체 활성) + const focusedNodeIds = useMemo | null>(() => { + if (!selectedNodeId) return null; + const connected = new Set([selectedNodeId]); + filteredEdges.forEach((e) => { + if (e.source === selectedNodeId) connected.add(e.target); + if (e.target === selectedNodeId) connected.add(e.source); + }); + return connected; + }, [selectedNodeId, filteredEdges]); + // React Flow 노드/엣지 const { nodes: rfNodes, edges: rfEdges } = useMemo( - () => buildLayout(filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId), - [filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId], + () => + buildLayout( + filteredNodes, + filteredEdges, + groupBy, + selectedNodeId, + selectedEdgeId, + focusedNodeIds, + ), + [filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId, focusedNodeIds], ); const selectedNode = useMemo( @@ -255,6 +302,11 @@ export function SystemFlowViewer() { setSelectedNodeId(null); }, []); + const onPaneClick = useCallback(() => { + setSelectedNodeId(null); + setSelectedEdgeId(null); + }, []); + const handleSelectNode = useCallback((id: string) => { setSelectedNodeId(id); setSelectedEdgeId(null); @@ -276,11 +328,20 @@ export function SystemFlowViewer() { onSelectNode={handleSelectNode} />
+ {focusedNodeIds && selectedNode && ( +
+ + {selectedNode.label} 연결 노드 {focusedNodeIds.size - 1}개만 표시 중 + + +
+ )}