Merge pull request 'release: System Flow 포커스 모드' (#11) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s

This commit is contained in:
htlee 2026-04-08 05:48:30 +09:00
커밋 4f1572cd4e
2개의 변경된 파일126개의 추가작업 그리고 15개의 파일을 삭제

파일 보기

@ -183,6 +183,48 @@ body,
background: #0b1220; 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 { .sf-canvas .react-flow__renderer {
background: #0b1220; background: #0b1220;
} }
@ -265,7 +307,15 @@ body,
} }
.sf-node-body[data-selected='true'] { .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 { .sf-node-stage {

파일 보기

@ -44,8 +44,8 @@ function matchesSearch(node: FlowNode, query: string): boolean {
// ─── 레이아웃 계산 (stage/menu 그리드) ────────────────── // ─── 레이아웃 계산 (stage/menu 그리드) ──────────────────
const COL_WIDTH = 360; const COL_WIDTH = 440;
const ROW_HEIGHT = 130; const ROW_HEIGHT = 170;
const COL_PADDING_X = 80; const COL_PADDING_X = 80;
const COL_PADDING_Y = 80; const COL_PADDING_Y = 80;
@ -60,6 +60,7 @@ function buildLayout(
groupBy: 'stage' | 'menu', groupBy: 'stage' | 'menu',
selectedNodeId: string | null, selectedNodeId: string | null,
selectedEdgeId: string | null, selectedEdgeId: string | null,
focusedNodeIds: Set<string> | null,
): LayoutResult { ): LayoutResult {
// 1. 그룹 키별로 노드 분류 (stage 모드는 정해진 순서, menu 모드는 정렬) // 1. 그룹 키별로 노드 분류 (stage 모드는 정해진 순서, menu 모드는 정렬)
const STAGE_ORDER = [ const STAGE_ORDER = [
@ -99,6 +100,7 @@ function buildLayout(
const y = COL_PADDING_Y + rowIdx * ROW_HEIGHT; const y = COL_PADDING_Y + rowIdx * ROW_HEIGHT;
const accent = stageColor(node.stage); const accent = stageColor(node.stage);
const isSelected = selectedNodeId === node.id; const isSelected = selectedNodeId === node.id;
const isDimmed = focusedNodeIds !== null && !focusedNodeIds.has(node.id);
positionedNodes.push({ positionedNodes.push({
id: node.id, id: node.id,
@ -110,11 +112,11 @@ function buildLayout(
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
width: 240, width: 240,
opacity: isDimmed ? 0.18 : 1,
transition: 'opacity 0.2s',
pointerEvents: isDimmed ? 'none' : 'auto',
}, },
// 커스텀 노드를 사용하지 않고 기본 노드의 라벨을 React 요소로 전달
// (별도 nodeTypes 등록 없이 동작하도록 className으로 처리)
} as Node); } as Node);
// 노드 라벨에 직접 JSX를 넣을 수 없으므로 className으로 처리하고 CSS로 dataset 사용
const last = positionedNodes[positionedNodes.length - 1]; const last = positionedNodes[positionedNodes.length - 1];
last.data = { last.data = {
label: ( label: (
@ -123,6 +125,7 @@ function buildLayout(
data-status={node.status} data-status={node.status}
data-trigger={node.trigger} data-trigger={node.trigger}
data-selected={isSelected} data-selected={isSelected}
data-dimmed={isDimmed}
style={{ ['--accent' as never]: accent }} style={{ ['--accent' as never]: accent }}
> >
<div className="sf-node-stage">{node.stage} · {node.kind}</div> <div className="sf-node-stage">{node.stage} · {node.kind}</div>
@ -135,7 +138,8 @@ function buildLayout(
}); });
}); });
// 3. 엣지 변환 // 3. 엣지 변환 — source별 offset으로 겹침 완화
const sourceCounter: Record<string, number> = {};
const positionedEdges: Edge[] = flowEdges.map((edge) => { const positionedEdges: Edge[] = flowEdges.map((edge) => {
const isSelected = selectedEdgeId === edge.id; const isSelected = selectedEdgeId === edge.id;
const color = const color =
@ -144,26 +148,49 @@ function buildLayout(
: edge.kind === 'trigger' : edge.kind === 'trigger'
? '#86efac' ? '#86efac'
: '#94a3b8'; : '#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 { return {
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
label: edge.label, label: edge.label,
type: 'smoothstep', type: 'smoothstep',
animated: isSelected || edge.kind === 'feedback', pathOptions: { offset: pathOffset, borderRadius: 14 },
animated: isHighlighted || (isFocused && edge.kind === 'feedback'),
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: isSelected ? '#f8fafc' : color, color: strokeColor,
width: 18, width: 18,
height: 18, height: 18,
}, },
style: { style: {
stroke: isSelected ? '#f8fafc' : color, stroke: strokeColor,
strokeWidth: isSelected ? 2.6 : 1.6, strokeWidth: isHighlighted ? 2.8 : 1.6,
strokeDasharray: edge.kind === 'feedback' ? '6 4' : undefined, strokeDasharray: edge.kind === 'feedback' ? '6 4' : undefined,
opacity,
transition: 'opacity 0.2s, stroke 0.2s, stroke-width 0.2s',
}, },
labelStyle: { fill: '#cbd5e1', fontSize: 10 }, labelStyle: { fill: isFocused ? '#cbd5e1' : '#475569', fontSize: 10 },
labelBgStyle: { fill: '#0f172a', fillOpacity: 0.9 }, labelBgStyle: { fill: '#0f172a', fillOpacity: isFocused ? 0.9 : 0.3 },
zIndex: isHighlighted ? 1000 : isFocused ? 10 : 0,
}; };
}); });
@ -230,10 +257,30 @@ export function SystemFlowViewer() {
); );
}, [filteredNodeIds]); }, [filteredNodeIds]);
// 포커스 모드: 선택된 노드가 있으면 1-hop 연결 노드 집합 계산
// 선택이 없거나 엣지가 선택되면 null (전체 활성)
const focusedNodeIds = useMemo<Set<string> | null>(() => {
if (!selectedNodeId) return null;
const connected = new Set<string>([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 노드/엣지 // React Flow 노드/엣지
const { nodes: rfNodes, edges: rfEdges } = useMemo( 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( const selectedNode = useMemo(
@ -255,6 +302,11 @@ export function SystemFlowViewer() {
setSelectedNodeId(null); setSelectedNodeId(null);
}, []); }, []);
const onPaneClick = useCallback(() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []);
const handleSelectNode = useCallback((id: string) => { const handleSelectNode = useCallback((id: string) => {
setSelectedNodeId(id); setSelectedNodeId(id);
setSelectedEdgeId(null); setSelectedEdgeId(null);
@ -276,11 +328,20 @@ export function SystemFlowViewer() {
onSelectNode={handleSelectNode} onSelectNode={handleSelectNode}
/> />
<div className="sf-canvas"> <div className="sf-canvas">
{focusedNodeIds && selectedNode && (
<div className="sf-focus-badge">
<span>
<strong>{selectedNode.label}</strong> {focusedNodeIds.size - 1}
</span>
<button type="button" onClick={onPaneClick}> </button>
</div>
)}
<ReactFlow <ReactFlow
nodes={rfNodes} nodes={rfNodes}
edges={rfEdges} edges={rfEdges}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick} onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
fitView fitView
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.2 }}
minZoom={0.1} minZoom={0.1}