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
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
This commit is contained in:
커밋
4f1572cd4e
@ -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 {
|
||||
|
||||
@ -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<string> | 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 }}
|
||||
>
|
||||
<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 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<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 노드/엣지
|
||||
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}
|
||||
/>
|
||||
<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
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user